<?php
declare(strict_types=1);

// ---------- Utilities ----------
function env_load(string $path): array {
  $vars = [];
  if (is_readable($path)) {
    foreach (file($path, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) as $line) {
      if (str_starts_with(trim($line), '#')) continue;
      $pos = strpos($line, '=');
      if ($pos === false) continue;
      $k = trim(substr($line, 0, $pos));
      $v = trim(substr($line, $pos+1));
      $v = trim($v, "'\"");
      $vars[$k] = $v;
      $_ENV[$k] = $v; $_SERVER[$k] = $v;
    }
  }
  return $vars;
}
$ROOT = dirname(__DIR__);
env_load($ROOT.'/.env');

function db(): PDO {
  static $pdo = null;
  if ($pdo) return $pdo;

  $host = $_ENV['DB_HOST'] ?? '127.0.0.1';
  $db   = $_ENV['DB_DATABASE'] ?? 'clicktrack_staging';
  $user = $_ENV['DB_USERNAME'] ?? 'clicktrack';
  $pass = $_ENV['DB_PASSWORD'] ?? '';
  $dsn  = "mysql:host=$host;dbname=$db;charset=utf8mb4";

  $pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ]);

  // Force all connections to use America/Chicago timezone
  try {
    $pdo->exec("SET time_zone = 'America/Chicago'");
  } catch (Throwable $_) {
    // If timezone tables aren’t loaded for some reason, don’t break the app.
  }

  return $pdo;
}


// ==== SAFE ERROR ENVELOPE HELPER ====
// Maps exceptions to safe, non-leaking error responses
// All stack traces and internal details logged only to error_log, never returned to client
function safe_error(Throwable $e, string $publicErrorCode = 'unknown_error', ?string $context = null, int $httpCode = 500): void {
  $isDev = ($_ENV['APP_ENV'] ?? 'production') === 'local';
  
  // Always log full exception details for debugging
  $logMsg = $context ? "$context: " : "";
  $logMsg .= $e->getMessage();
  if ($isDev) {
    $logMsg .= " at " . $e->getFile() . ":" . $e->getLine();
  }
  error_log($logMsg);
  
  // Return only safe, generic error to client
  http_response_code($httpCode);
  header('Content-Type: application/json; charset=utf-8');
  echo json_encode([
    'error' => $publicErrorCode,
    'ok' => false
  ]);
  exit;
}

// ==== CT_AUTH_LOGIN_HANDLER: begin ====
require_once __DIR__ . "/../app/auth_policy.php";
require_once __DIR__ . "/../app/security_logger.php";
require_once __DIR__ . "/../app/IpBlockManager.php";
require_once __DIR__ . "/../app/rate_limiter.php";
ct_start_auth_session();

function ct_login_db(): PDO {
    return db();
}


(function () {
    $path = parse_url($_SERVER["REQUEST_URI"] ?? "", PHP_URL_PATH) ?: "/";
    if (($_SERVER["REQUEST_METHOD"] ?? "GET") === "POST" && $path === "/login") {
        header("Content-Type: application/json; charset=utf-8");
        $raw = file_get_contents("php://input");
        $data = json_decode($raw, true);
        if (!is_array($data)) { http_response_code(422); echo json_encode(["error"=>"Invalid JSON"]); exit; }
        $email = trim((string)($data["email"] ?? ""));
        $password = (string)($data["password"] ?? "");
        if ($email === "" || $password === "") { http_response_code(422); echo json_encode(["error"=>"email and password required"]); exit; }
        try {
            $pdo = ct_login_db();

$stmt = $pdo->prepare("SELECT id,email,name,role,password_hash FROM users WHERE email = ? LIMIT 1");
            $stmt->execute([$email]);
            $user = $stmt->fetch();
            $verify = $user ? password_verify($password, $user["password_hash"]) : null;
if (!$user || !$verify) {
  SecurityLogger::logAuthFail('invalid_credentials', [
    'email' => $email,
    'path' => '/login',
  ]);
    http_response_code(401);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(["error"=>"Invalid credentials"]);
    exit;
}

            ct_regenerate_session();
            $_SESSION["auth"] = [
                "id"    => (int)$user["id"],
                "email" => $user["email"],
                "name"  => $user["name"],
                "role"  => $user["role"],
            ];
            if (empty($_SESSION["csrf"])) { $_SESSION["csrf"] = bin2hex(random_bytes(32)); }
            echo json_encode(["ok"=>true, "role"=>$_SESSION["auth"]["role"], "csrf"=>$_SESSION["csrf"]]);
            exit;


        } catch (Throwable $e) {
            http_response_code(500);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => "server_error"]);
            exit;
        }
    }
})();





// ==== CT_AUTH_LOGIN_HANDLER: end ====


// ==== CT_AUTH_ME_HANDLER: begin ====
(function () {
    $path   = parse_url($_SERVER["REQUEST_URI"] ?? "", PHP_URL_PATH) ?: "/";
    $method = $_SERVER["REQUEST_METHOD"] ?? "GET";


if ($method === "GET" && $path === "/me") {
    header("Content-Type: application/json; charset=utf-8");
    ct_start_auth_session();
    $auth = $_SESSION["auth"] ?? null;
    $csrf = $_SESSION["csrf"] ?? null;

    if (!$auth) {
        echo json_encode(["ok" => false, "auth" => null, "csrf" => null]);
        exit;
    }
    
    // Check if bearer token is expiring within 7 days for admin/superadmin users
    $userId = (int)($auth["id"] ?? 0);
    $userRole = (string)($auth["role"] ?? '');
    
    if ($userId > 0 && in_array($userRole, ['admin', 'superadmin'], true)) {
        try {
            $pdo = db();
            $stmt = $pdo->prepare("SELECT token_expires_at FROM users WHERE id = ? LIMIT 1");
            $stmt->execute([$userId]);
            $user = $stmt->fetch(PDO::FETCH_ASSOC);
            
            if ($user && !empty($user['token_expires_at'])) {
                $expiresAt = strtotime($user['token_expires_at']);
                $now = time();
                $daysUntilExpiry = ($expiresAt - $now) / 86400;
                
                // Check if notifications table exists
                $tablesStmt = $pdo->query("SHOW TABLES LIKE 'notifications'");
                $notificationsTableExists = (bool)$tablesStmt->fetch();
                
                if ($notificationsTableExists) {
                    if ($daysUntilExpiry > 0 && $daysUntilExpiry <= 7) {
                        // Token expiring soon - create notification if not already exists
                        $daysRemaining = (int)ceil($daysUntilExpiry);
                        $notifType = 'token_expiry_warning';
                        $notifMessage = "⚠️ Your bearer token will expire in {$daysRemaining} day" . ($daysRemaining !== 1 ? 's' : '') . ". Please regenerate it in User Settings to avoid disruption.";
                        
                        // Check if notification already exists (avoid spam)
                        $checkStmt = $pdo->prepare("SELECT id FROM notifications WHERE user_id = ? AND type = ? AND read_at IS NULL LIMIT 1");
                        $checkStmt->execute([$userId, $notifType]);
                        
                        if (!$checkStmt->fetch()) {
                            // Create notification
                            $createStmt = $pdo->prepare("INSERT INTO notifications (user_id, type, message, created_by) VALUES (?, ?, ?, NULL)");
                            $createStmt->execute([$userId, $notifType, $notifMessage]);
                        }
                    } elseif ($expiresAt < $now) {
                        // Token expired - create notification if not already exists
                        $notifType = 'token_expired';
                        $notifMessage = "🚨 Your bearer token has EXPIRED. Please regenerate it in User Settings to continue using API authentication.";
                        
                        // Check if notification already exists (avoid spam)
                        $checkStmt = $pdo->prepare("SELECT id FROM notifications WHERE user_id = ? AND type = ? AND read_at IS NULL LIMIT 1");
                        $checkStmt->execute([$userId, $notifType]);
                        
                        if (!$checkStmt->fetch()) {
                            // Create notification
                            $createStmt = $pdo->prepare("INSERT INTO notifications (user_id, type, message, created_by) VALUES (?, ?, ?, NULL)");
                            $createStmt->execute([$userId, $notifType, $notifMessage]);
                        }
                    }
                }
            }
        } catch (\Throwable $_) {
            // Ignore errors in token expiry check/notification creation
        }
    }

    $response = [
        "ok"   => true,
        "auth" => [
            "id"    => (int)$auth["id"],
            "email" => $auth["email"],
            "name"  => $auth["name"],
            "role"  => $auth["role"],
        ],
        "csrf" => $csrf,
    ];
    
    echo json_encode($response);
    exit;
}




})();
// ==== CT_AUTH_ME_HANDLER: end ====


// ==== CT_AUTH_LOGOUT_HANDLER: begin ====
(function () {
    $path   = parse_url($_SERVER["REQUEST_URI"] ?? "", PHP_URL_PATH) ?: "/";
    $method = $_SERVER["REQUEST_METHOD"] ?? "GET";
    if ($method === "POST" && $path === "/logout") {
        header("Content-Type: application/json; charset=utf-8");
        ct_start_auth_session();
        // Clear session
        $_SESSION = [];
        if (ini_get("session.use_cookies")) {
            $p = session_get_cookie_params();
            setcookie(session_name(), '', time() - 42000, $p["path"], $p["domain"], $p["secure"], $p["httponly"]);
        }
        session_destroy();
        // Fresh empty session id (defense-in-depth)
        ct_start_auth_session();
        ct_regenerate_session();
        echo json_encode(["ok" => true]);
        exit;
    }
})();
// ==== CT_AUTH_LOGOUT_HANDLER: end ====



// ==== CT_STATS_15M_HANDLER: begin ====
(function () {
    $path   = parse_url($_SERVER["REQUEST_URI"] ?? "", PHP_URL_PATH) ?: "/";
    $method = $_SERVER["REQUEST_METHOD"] ?? "GET";
    if ($method !== "GET" || $path !== "/stats15m") { return; }

    header("Content-Type: application/json; charset=utf-8");

    // id-only: zone_id (int) required
    $zoneId = isset($_GET["zone_id"]) && ctype_digit((string)$_GET["zone_id"])
        ? (int)$_GET["zone_id"] : 0;
    if ($zoneId < 1) {
        echo json_encode(["ok"=>false, "error"=>"missing_or_bad_zone_id"]);
        exit;
    }

    try {
        $pdo = db();

        // Clicks in last 15 minutes for this zone_id (join via tracking_urls)
        $stC = $pdo->prepare("
            SELECT COUNT(*) AS c
            FROM click_logs c
            JOIN tracking_urls tu ON tu.id = c.tracking_url_id
            WHERE tu.zone_id = ? AND c.ts >= (NOW() - INTERVAL 15 MINUTE)
        ");
        $stC->execute([$zoneId]);
        $clicks = (int)($stC->fetchColumn() ?: 0);

        // Impressions in last 15 minutes for this zone_id (join via tracking_urls)
        $stI = $pdo->prepare("
            SELECT COUNT(*) AS c
            FROM impression_logs i
            JOIN tracking_urls tu ON tu.id = i.tracking_url_id
            WHERE tu.zone_id = ? AND i.ts >= (NOW() - INTERVAL 15 MINUTE)
        ");
        $stI->execute([$zoneId]);
        $imps = (int)($stI->fetchColumn() ?: 0);

        echo json_encode([
            "ok"              => true,
            "zone_id"         => $zoneId,
            "clicks"          => $clicks,
            "impressions"     => $imps,
            "window_minutes"  => 15
        ], JSON_UNESCAPED_SLASHES);
        exit;

    } catch (Throwable $e) {
        http_response_code(500);
        echo json_encode(["ok"=>false, "error"=>"server_error"]);
        exit;
    }
})();
// ==== CT_STATS_15M_HANDLER: end ====





// ClickTrack+ Router v0.2.0 (UTM-aware, schema-resilient)
// PHP 8.2 compatible. Single-file router for staging.

// Load bot detector
require_once __DIR__ . '/../app/BotDetector.php';

/**
 * Load custom Bot Defense config for a tracking URL, merging with defaults
 * 
 * Resolves zone_id and partner_id from tracking_urls, then loads custom
 * config from bot_config_custom. Falls back to defaults if no custom config exists.
 */
function get_bot_detector(PDO $pdo, int $trackingUrlId, ?int $zoneId = null, ?int $partnerId = null): BotDetector {
    // Default config (matches previously-saved defaults)
    $defaultConfig = [
        'block_bots' => true,
        'block_datacenters' => false,
        'min_click_interval' => 2,
        'min_impression_interval' => 1,
        'max_clicks_per_hour' => 100,
        'max_impressions_per_hour' => 500,
    ];
    
    // If zone/partner IDs not provided, look them up from tracking_urls
    try {
        if (($zoneId === null || $partnerId === null) && $trackingUrlId > 0) {
            $st = $pdo->prepare('SELECT zone_id FROM tracking_urls WHERE id = ? LIMIT 1');
            $st->execute([$trackingUrlId]);
            $row = $st->fetch();
            
            if ($row && !empty($row['zone_id'])) {
                $zoneId = (int)$row['zone_id'];
                
                // Get partner_id from partner_zones
                if ($partnerId === null && $zoneId > 0) {
                    $st2 = $pdo->prepare('SELECT partner_id FROM partner_zones WHERE id = ? LIMIT 1');
                    $st2->execute([$zoneId]);
                    $row2 = $st2->fetch();
                    if ($row2 && !empty($row2['partner_id'])) {
                        $partnerId = (int)$row2['partner_id'];
                    }
                }
            }
        }
        
        // Load custom config if we have partner/zone identifiers
        if ($partnerId !== null || $zoneId !== null) {
            $st = $pdo->prepare('
                SELECT config_key, config_value FROM bot_config_custom
                WHERE (partner_id = ? OR partner_id IS NULL)
                  AND (zone_id = ? OR zone_id IS NULL)
                ORDER BY partner_id DESC, zone_id DESC
            ');
            $st->execute([$partnerId ?? 0, $zoneId ?? 0]);
            
            $customConfig = [];
            while ($row = $st->fetch()) {
                $key = $row['config_key'] ?? '';
                $val = $row['config_value'] ?? '';
                
                // Convert string values to proper types
                if ($val === 'true' || $val === '1') {
                    $customConfig[$key] = true;
                } elseif ($val === 'false' || $val === '0') {
                    $customConfig[$key] = false;
                } elseif (is_numeric($val)) {
                    $customConfig[$key] = (int)$val;
                } else {
                    $customConfig[$key] = $val;
                }
            }
            
            // Merge custom config with defaults (custom overrides)
            $mergedConfig = array_merge($defaultConfig, $customConfig);
        } else {
            $mergedConfig = $defaultConfig;
        }
    } catch (Throwable $_) {
        // If anything fails loading custom config, fall back to defaults
        $mergedConfig = $defaultConfig;
    }
    
    return new BotDetector($pdo, $mergedConfig);
}

function json($data, ?int $code=null): void {
  if ($code !== null) {
    http_response_code($code);
  }
  header('Content-Type: application/json; charset=utf-8');
  echo json_encode($data, JSON_UNESCAPED_SLASHES);
  exit;
}



function text(string $s, int $code=200, string $ctype='text/plain; charset=utf-8'): void {
  http_response_code($code);
  header("Content-Type: $ctype");
  echo $s;
  exit;
}
function observability_dir(): string {
  $configured = trim((string)($_ENV['CT_OBSERVABILITY_DIR'] ?? ''));
  $primary = $configured !== '' ? $configured : (dirname(__DIR__) . '/storage/logs/observability');

  if (is_dir($primary) || @mkdir($primary, 0775, true)) {
    if (is_writable($primary)) {
      return $primary;
    }
  }

  $fallback = rtrim(sys_get_temp_dir(), '/\\') . '/clicktracker-observability';
  if (!is_dir($fallback)) {
    @mkdir($fallback, 0775, true);
  }
  return $fallback;
}
function observability_file_for_date(string $date): string {
  return observability_dir() . '/events-' . $date . '.ndjson';
}
function observability_read_events_for_date(string $date, array $filters = []): array {
  $file = observability_file_for_date($date);
  if (!is_file($file) || !is_readable($file)) {
    return [];
  }

  $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
  if ($lines === false) {
    return [];
  }

  $events = [];
  foreach ($lines as $line) {
    $event = json_decode($line, true);
    if (!is_array($event)) {
      continue;
    }

    if (isset($filters['event_type']) && $filters['event_type'] !== '' && ($event['event_type'] ?? '') !== $filters['event_type']) {
      continue;
    }
    if (isset($filters['severity']) && $filters['severity'] !== '' && ($event['severity'] ?? '') !== $filters['severity']) {
      continue;
    }
    if (isset($filters['ip']) && $filters['ip'] !== '' && ($event['ip'] ?? '') !== $filters['ip']) {
      continue;
    }
    if (isset($filters['source']) && $filters['source'] !== '' && ($event['source'] ?? '') !== $filters['source']) {
      continue;
    }

    $events[] = $event;
  }

  return $events;
}
function observability_summary(array $events): array {
  $byType = [];
  $bySeverity = [];
  $bySource = [];
  $hourly = array_fill(0, 24, 0);

  foreach ($events as $event) {
    $type = (string)($event['event_type'] ?? 'unknown');
    $severity = (string)($event['severity'] ?? 'info');
    $source = (string)($event['source'] ?? 'unknown');

    $byType[$type] = ($byType[$type] ?? 0) + 1;
    $bySeverity[$severity] = ($bySeverity[$severity] ?? 0) + 1;
    $bySource[$source] = ($bySource[$source] ?? 0) + 1;

    $ts = (string)($event['timestamp'] ?? '');
    if ($ts !== '') {
      $hour = (int)date('G', strtotime($ts));
      if ($hour >= 0 && $hour <= 23) {
        $hourly[$hour]++;
      }
    }
  }

  arsort($byType);
  arsort($bySeverity);
  arsort($bySource);

  return [
    'total' => count($events),
    'by_event_type' => $byType,
    'by_severity' => $bySeverity,
    'by_source' => $bySource,
    'hourly_counts' => $hourly,
    'dashboard' => [
      'auth_failures' => (int)($byType['auth_fail'] ?? 0),
      'rate_limit_events' => (int)($byType['rate_limit'] ?? 0),
      'bot_blocks' => (int)($byType['bot_block'] ?? 0),
      'critical_events' => (int)($bySeverity['critical'] ?? 0) + (int)($bySeverity['error'] ?? 0),
    ],
  ];
}
function system_settings_table_ready(PDO $pdo): bool {
  return (bool)$pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
}
function system_settings_get(PDO $pdo, string $settingKey, mixed $default = null): mixed {
  if (!system_settings_table_ready($pdo)) {
    return $default;
  }

  $stmt = $pdo->prepare("SELECT setting_value, setting_type FROM system_settings WHERE setting_key = ? LIMIT 1");
  $stmt->execute([$settingKey]);
  $row = $stmt->fetch(PDO::FETCH_ASSOC);
  if (!$row) {
    return $default;
  }

  $value = (string)($row['setting_value'] ?? '');
  $type = (string)($row['setting_type'] ?? 'string');

  if ($type === 'boolean') {
    return in_array(strtolower($value), ['true', '1', 'yes'], true);
  }
  if ($type === 'integer') {
    return (int)$value;
  }
  if ($type === 'json') {
    $decoded = json_decode($value, true);
    return is_array($decoded) ? $decoded : $default;
  }

  return $value;
}
function system_settings_upsert(PDO $pdo, string $settingKey, mixed $value, string $settingType = 'string'): void {
  if (!system_settings_table_ready($pdo)) {
    return;
  }

  $storedValue = $value;
  if ($settingType === 'boolean') {
    $storedValue = $value ? 'true' : 'false';
  } elseif ($settingType === 'integer') {
    $storedValue = (string)(int)$value;
  } elseif ($settingType === 'json') {
    $storedValue = json_encode($value, JSON_UNESCAPED_SLASHES);
  } else {
    $storedValue = (string)$value;
  }

  $stmt = $pdo->prepare("INSERT INTO system_settings (setting_key, setting_value, setting_type)
    VALUES (?, ?, ?)
    ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), setting_type = VALUES(setting_type), updated_at = NOW()");
  $stmt->execute([$settingKey, $storedValue, $settingType]);
}

function ct_default_publication_presets(): array {
  return [
    [
      'key' => 'generic',
      'label' => 'Generic',
      'uid_macro' => '{{uid}}',
      'cid_macro' => '{{send_id}}',
      'email_macro' => '{{email}}',
      'enabled' => true,
    ],
    [
      'key' => 'brevo',
      'label' => 'Brevo',
      'uid_macro' => '{{ contact.EMAIL }}',
      'cid_macro' => '{{ params.CAMP_ID }}',
      'email_macro' => '{{ contact.EMAIL }}',
      'enabled' => true,
    ],
    [
      'key' => 'campaigner',
      'label' => 'Campaigner',
      'uid_macro' => '[Campaign.Id][Contact.Email]',
      'cid_macro' => '[CampaignID]',
      'email_macro' => '[Contact.Email]',
      'enabled' => true,
    ],
    [
      'key' => 'hubspot',
      'label' => 'HubSpot',
      'uid_macro' => '{{contact.email}}',
      'cid_macro' => '{{ content.campaign_id }}',
      'email_macro' => '{{contact.email}}',
      'enabled' => true,
    ],
    [
      'key' => 'informz',
      'label' => 'Informz',
      'uid_macro' => '%%UID%%',
      'cid_macro' => '%%CampaignID%%',
      'email_macro' => '%%Email%%',
      'enabled' => true,
    ],
    [
      'key' => 'mailchimp',
      'label' => 'Mailchimp',
      'uid_macro' => '*|EMAIL|*',
      'cid_macro' => '*|CAMPAIGN_UID|*',
      'email_macro' => '*|EMAIL|*',
      'enabled' => true,
    ],
    [
      'key' => 'marketo',
      'label' => 'Marketo',
      'uid_macro' => '{{lead.Email Address}}',
      'cid_macro' => '{{campaign.id}}',
      'email_macro' => '{{lead.Email Address}}',
      'enabled' => true,
    ],
  ];
}

function ct_normalize_publication_presets(mixed $value): array {
  $defaults = ct_default_publication_presets();
  if (!is_array($value)) {
    return $defaults;
  }

  $normalized = [];
  foreach ($value as $row) {
    if (!is_array($row)) {
      continue;
    }

    $key = strtolower(trim((string)($row['key'] ?? '')));
    $label = trim((string)($row['label'] ?? ''));
    $uidMacro = trim((string)($row['uid_macro'] ?? ''));
    $cidMacro = trim((string)($row['cid_macro'] ?? ''));
    $emailMacro = trim((string)($row['email_macro'] ?? ''));
    $enabled = array_key_exists('enabled', $row) ? (bool)$row['enabled'] : true;

    if ($key === '') {
      continue;
    }
    if (!preg_match('/^[a-z0-9_-]{2,40}$/', $key)) {
      continue;
    }
    if ($label === '') {
      $label = strtoupper($key);
    }
    if ($uidMacro === '') {
      continue;
    }
    if ($cidMacro === '') {
      $cidMacro = '{{send_id}}';
    }

    $normalized[] = [
      'key' => $key,
      'label' => substr($label, 0, 80),
      'uid_macro' => substr($uidMacro, 0, 250),
      'cid_macro' => substr($cidMacro, 0, 250),
      'email_macro' => substr($emailMacro, 0, 250),
      'enabled' => $enabled,
    ];
  }

  if (!$normalized) {
    return $defaults;
  }

  return array_values($normalized);
}

function ct_publication_settings_payload(PDO $pdo): array {
  $presets = ct_normalize_publication_presets(system_settings_get($pdo, 'publication.esp_presets', ct_default_publication_presets()));
  $enabledPresets = array_values(array_filter($presets, static fn(array $preset): bool => !empty($preset['enabled'])));
  if (!$enabledPresets) {
    $enabledPresets = $presets;
  }

  $defaultPreset = strtolower(trim((string)system_settings_get($pdo, 'publication.default_preset', 'generic')));
  $allowedKeys = array_column($enabledPresets, 'key');
  if (!in_array($defaultPreset, $allowedKeys, true)) {
    $defaultPreset = isset($enabledPresets[0]['key']) ? (string)$enabledPresets[0]['key'] : 'generic';
  }

  return [
    'default_preset' => $defaultPreset,
    'presets' => $enabledPresets,
    'all_presets' => $presets,
    'table_ready' => system_settings_table_ready($pdo),
  ];
}

function ct_resolve_backup_root(string $appRoot): string {
  $defaultRoot = $appRoot . '/storage/backups';
  $configuredRoot = trim((string)(getenv('BACKUP_ROOT') ?: ''));
  $root = $configuredRoot !== '' ? $configuredRoot : $defaultRoot;

  // Mirror backup.sh fallback behavior when configured root exists but is not writable.
  if (file_exists($root) && !is_writable($root)) {
    $root = $defaultRoot;
  }

  return $root;
}

function ct_ping_redis_url(string $redisUrl): array {
  if (trim($redisUrl) === '') {
    return ['ok' => false, 'error' => 'missing_url'];
  }
  if (!class_exists('Redis')) {
    return ['ok' => false, 'error' => 'redis_extension_missing'];
  }

  $parts = parse_url($redisUrl);
  if (!is_array($parts)) {
    return ['ok' => false, 'error' => 'invalid_redis_url'];
  }

  $host = (string)($parts['host'] ?? '127.0.0.1');
  $port = (int)($parts['port'] ?? 6379);
  $password = isset($parts['pass']) ? (string)$parts['pass'] : '';
  $dbIndex = isset($parts['path']) ? max(0, (int)ltrim((string)$parts['path'], '/')) : 0;

  try {
    $redis = new Redis();
    $redis->connect($host, $port, 1.5);
    if ($password !== '') {
      $redis->auth($password);
    }
    if ($dbIndex > 0) {
      $redis->select($dbIndex);
    }

    $pong = $redis->ping();
    $ok = is_string($pong) ? stripos($pong, 'PONG') !== false : ($pong === true);
    return ['ok' => $ok, 'error' => $ok ? null : 'ping_failed'];
  } catch (Throwable $e) {
    return ['ok' => false, 'error' => 'redis_connect_failed'];
  }
}
function require_session_auth(?array $roles=null): array {
  $auth = ct_auth_session_user();
  if (!$auth) { json(['error' => 'auth_required'], 401); }
  if ($roles && !ct_auth_has_session_role($roles, $auth)) { json(['error' => 'forbidden'], 403); }
  return $auth;
}
function parse_optional_positive_int(mixed $value): int|null|false {
  if ($value === null || $value === '') return null;
  if (is_int($value)) {
    return $value > 0 ? $value : false;
  }
  if (is_string($value)) {
    $trimmed = trim($value);
    if ($trimmed === '' || !ctype_digit($trimmed)) return false;
    $parsed = (int)$trimmed;
    return $parsed > 0 ? $parsed : false;
  }
  if (is_float($value)) {
    $parsed = (int)$value;
    return ($parsed > 0 && ((float)$parsed === $value)) ? $parsed : false;
  }
  return false;
}
function resolve_bot_config_scope(PDO $pdo, ?int $partnerId, ?int $zoneId): array {
  if ($zoneId !== null) {
    $zoneSt = $pdo->prepare('SELECT id, partner_id FROM partner_zones WHERE id = ? LIMIT 1');
    $zoneSt->execute([$zoneId]);
    $zoneRow = $zoneSt->fetch(PDO::FETCH_ASSOC);
    if (!$zoneRow) {
      return ['ok' => false, 'status' => 404, 'error' => 'zone_not_found'];
    }

    $zonePartnerId = !empty($zoneRow['partner_id']) ? (int)$zoneRow['partner_id'] : null;
    if ($zonePartnerId === null || $zonePartnerId < 1) {
      return ['ok' => false, 'status' => 422, 'error' => 'zone_missing_partner'];
    }

    if ($partnerId === null) {
      $partnerId = $zonePartnerId;
    } elseif ($partnerId !== $zonePartnerId) {
      return ['ok' => false, 'status' => 422, 'error' => 'zone_partner_mismatch'];
    }
  }

  if ($partnerId !== null) {
    $partnerSt = $pdo->prepare('SELECT id FROM partners WHERE id = ? LIMIT 1');
    $partnerSt->execute([$partnerId]);
    if (!$partnerSt->fetch(PDO::FETCH_ASSOC)) {
      return ['ok' => false, 'status' => 404, 'error' => 'partner_not_found'];
    }
  }

  return [
    'ok' => true,
    'partner_id' => $partnerId,
    'zone_id' => $zoneId,
  ];
}
function tickets_table_ready(PDO $pdo): bool {
  try {
    $pdo->query('SELECT 1 FROM tickets LIMIT 1');
    return true;
  } catch (Throwable $e) {
    if ($e instanceof PDOException) {
      $info = $e->errorInfo ?? [];
      $code = $info[1] ?? null;
      if (($e->getCode() === '42S02') || ($code === 1146)) {
        return false;
      }
    }
    throw $e;
  }
}
function bearer_token(): ?string {
  return ct_auth_bearer_token();
}
function request_header(string $name): ?string {
  $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
  $v = $_SERVER[$key] ?? null;
  if (is_string($v) && $v !== '') return $v;
  return null;
}
function require_admin_csrf_for_mutation(bool $hasToken, string $method): void {
  $m = strtoupper($method);
  if (!in_array($m, ['POST','PUT','PATCH','DELETE'], true)) return;

  // Valid bearer-token calls are not cookie-CSRF vulnerable.
  if ($hasToken) return;

  ct_start_auth_session();
  if (empty($_SESSION['auth']) || !is_array($_SESSION['auth'])) {
    json(['error' => 'auth_required'], 401);
  }
  if (empty($_SESSION['csrf']) || !is_string($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
  }

  $provided = request_header('X-CSRF-Token')
    ?? request_header('X-CSRF')
    ?? '';
  $expected = (string)($_SESSION['csrf'] ?? '');

  if ($provided === '' || $expected === '' || !hash_equals($expected, $provided)) {
    $auth = $_SESSION['auth'] ?? null;
    $user = is_array($auth) ? [
      'id' => $auth['id'] ?? null,
      'email' => $auth['email'] ?? null,
      'role' => $auth['role'] ?? null,
    ] : null;
    
    SecurityLogger::logCsrfFail(
      $provided === '' ? 'missing_token' : 'invalid_token',
      $user
    );
    
    json(['error' => 'csrf_invalid'], 403);
  }
}
function client_ip(): string {
  foreach (['HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR','HTTP_X_REAL_IP','REMOTE_ADDR'] as $k) {
    if (!empty($_SERVER[$k])) { $v = trim(explode(',', $_SERVER[$k])[0]); if ($v) return $v; }
  }
  return '0.0.0.0';
}
function bot_whitelist_has_expires_at(PDO $pdo): bool {
  static $cached = null;
  if ($cached !== null) return $cached;

  try {
    $st = $pdo->prepare("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bot_whitelist' AND COLUMN_NAME = 'expires_at' LIMIT 1");
    $st->execute();
    $cached = (bool)$st->fetch(PDO::FETCH_ASSOC);
  } catch (Throwable $_) {
    $cached = false;
  }

  return $cached;
}
function bot_whitelist_is_active_for_ip(PDO $pdo, string $ip): bool {
  if ($ip === '') return false;

  try {
    if (bot_whitelist_has_expires_at($pdo)) {
      $st = $pdo->prepare("SELECT COUNT(*) FROM bot_whitelist WHERE ip = ? AND active = 1 AND (expires_at IS NULL OR expires_at > NOW())");
      $st->execute([$ip]);
      return (int)$st->fetchColumn() > 0;
    }

    $st = $pdo->prepare("SELECT COUNT(*) FROM bot_whitelist WHERE ip = ? AND active = 1");
    $st->execute([$ip]);
    return (int)$st->fetchColumn() > 0;
  } catch (Throwable $_) {
    return false;
  }
}
function rate_limit(string $bucket, int $limit, int $windowSec): void {
  $result = ct_rate_limit_allow($bucket, $limit, $windowSec);
  if (!(bool)($result['allowed'] ?? false)) {
    header('Retry-After: ' . (int)($result['retry_after'] ?? max(1, $windowSec)));
    json(['error' => 'rate_limited'], 429);
  }
}

// ---------- Domain Safety ----------
function is_private_ip(string $host): bool {
  $ip = $host;
  if (!filter_var($host, FILTER_VALIDATE_IP)) {
    $res = @dns_get_record($host, DNS_A + DNS_AAAA);
    if (!$res) return false;
    $first = $res[0];
    $ip = $first['ip'] ?? ($first['ipv6'] ?? '');
  }
  if (!$ip) return false;
  if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
    return true;
  }
  return false;
}
function domain_allowed(string $url): bool {
  $p = parse_url($url); if (!$p || empty($p['host'])) return false;
  $host = strtolower($p['host']);
  if (is_private_ip($host)) return false;
  $deny = array_filter(array_map('trim', explode(',', $_ENV['DENYLIST_DOMAINS'] ?? '')));
  foreach ($deny as $d) { if ($d !== '' && str_ends_with($host, strtolower($d))) return false; }
  $allow = array_filter(array_map('trim', explode(',', $_ENV['ALLOWLIST_DOMAINS'] ?? '')));
  if ($allow) {
    foreach ($allow as $a) { if ($a !== '' && str_ends_with($host, strtolower($a))) return true; }
    return false;
  }
  return true;
}

// ---------- Schema helpers ----------
function tracking_columns(): array {
  static $cols = null;
  if ($cols !== null) return $cols;
  $stmt = db()->query('SHOW COLUMNS FROM tracking_urls');
  $cols = [];
  foreach ($stmt->fetchAll() as $c) { $cols[$c['Field']] = true; }
  return $cols;
}
function log_columns(string $table): array {
  static $cache = [];
  if (isset($cache[$table])) return $cache[$table];
  $stmt = db()->query('SHOW COLUMNS FROM '.$table);
  $cols = [];
  foreach ($stmt->fetchAll() as $c) { $cols[$c['Field']] = true; }
  $cache[$table] = $cols;
  return $cols;
}
function dest_col(): ?string {
  $c = tracking_columns();
  foreach (['dest_url','destination','destination_url','full_url','url'] as $cand) {
    if (isset($c[$cand])) return $cand;
  }
  return null;
}

// ---------- UTM Merge ----------
function parse_query(string $url): array {
  $q = []; $p = parse_url($url); if (!empty($p['query'])) parse_str($p['query'], $q); return $q;
}
function build_url_with_query(string $url, array $params): string {
  $p = parse_url($url);
  $scheme = $p['scheme'] ?? 'https';
  $host   = $p['host']   ?? '';
  $path   = $p['path']   ?? '';
  $port   = isset($p['port']) ? ':'.$p['port'] : '';
  $frag   = isset($p['fragment']) ? '#'.$p['fragment'] : '';
  $query  = http_build_query($params);
  return $scheme.'://'.$host.$port.$path.($query ? '?'.$query : '').$frag;
}
function utm_defaults(array $row): array {
  $d = [];
  foreach (['utm_source','utm_medium','utm_campaign','utm_content'] as $k) { if (!empty($row[$k])) $d[$k] = $row[$k]; }
  return $d;
}
function merge_utms(string $destUrl, array $clientQuery, array $serverDefaults, string $policy): string {
  $destQ = parse_query($destUrl);
  $isUtm = fn($k) => str_starts_with(strtolower($k), 'utm_');
  if ($policy === 'override_always') {
    $result = $destQ;
    foreach ($clientQuery as $k=>$v) { if (!$isUtm($k)) $result[$k] = $v; }
    foreach ($serverDefaults as $k=>$v) { $result[$k] = $v; }
  } elseif ($policy === 'ignore') {
    $result = $destQ;
  } else {
    $result = array_merge($destQ, $clientQuery);
    foreach ($serverDefaults as $k=>$v) {
      if (!array_key_exists($k, $result) || $result[$k] === '' || $result[$k] === null) $result[$k] = $v;
    }
  }
  return build_url_with_query($destUrl, $result);
}

// ---------- Validation Engine ----------
function ct_table_exists(PDO $pdo, string $tableName): bool {
  static $cache = [];
  if (array_key_exists($tableName, $cache)) {
    return (bool)$cache[$tableName];
  }

  $stmt = $pdo->prepare('SHOW TABLES LIKE ?');
  $stmt->execute([$tableName]);
  $cache[$tableName] = (bool)$stmt->fetchColumn();
  return (bool)$cache[$tableName];
}
function ct_validation_issue(string $severity, string $code, string $title, string $detail, array $meta = []): array {
  return [
    'severity' => $severity,
    'code' => $code,
    'title' => $title,
    'detail' => $detail,
    'meta' => $meta,
  ];
}
function ct_validation_add_issue(array &$result, string $severity, string $code, string $title, string $detail, array $meta = []): void {
  $result['issues'][] = ct_validation_issue($severity, $code, $title, $detail, $meta);
}
function ct_validation_new_result(string $scopeType, int $scopeId): array {
  return [
    'scope_type' => $scopeType,
    'scope_id' => $scopeId,
    'status' => 'pass',
    'issues' => [],
    'summary' => [
      'error_count' => 0,
      'warning_count' => 0,
      'info_count' => 0,
      'checked_at' => gmdate('c'),
    ],
  ];
}
function ct_validation_finalize(array &$result): void {
  $errors = 0;
  $warnings = 0;
  $info = 0;

  foreach (($result['issues'] ?? []) as $issue) {
    $severity = (string)($issue['severity'] ?? 'info');
    if ($severity === 'error') {
      $errors++;
      continue;
    }
    if ($severity === 'warning') {
      $warnings++;
      continue;
    }
    $info++;
  }

  $result['summary']['error_count'] = $errors;
  $result['summary']['warning_count'] = $warnings;
  $result['summary']['info_count'] = $info;
  $result['status'] = $errors > 0 ? 'fail' : ($warnings > 0 ? 'warn' : 'pass');
}
function ct_zone_item_compatible(array $zone, array $item): bool {
  $zw = (int)($zone['width'] ?? 0);
  $zh = (int)($zone['height'] ?? 0);
  $iw = (int)($item['width'] ?? 0);
  $ih = (int)($item['height'] ?? 0);

  // Mirror serving logic: exact size match is required only when zone size is set.
  if ($zw > 0 && $zh > 0) {
    return ($iw === $zw && $ih === $zh);
  }

  return true;
}
function ct_validate_ad_item(PDO $pdo, int $adItemId): array {
  $result = ct_validation_new_result('ad_item', $adItemId);

  $stmt = $pdo->prepare("\n    SELECT\n      ai.id, ai.campaign_id, ai.name, ai.type, ai.status, ai.target, ai.width, ai.height, ai.asset_path, ai.start_at, ai.end_at, ai.deleted_at,\n      c.id AS campaign_exists, c.status AS campaign_status, c.start_at AS campaign_start_at, c.end_at AS campaign_end_at, c.deleted_at AS campaign_deleted_at\n    FROM ad_items ai\n    LEFT JOIN campaigns c ON c.id = ai.campaign_id\n    WHERE ai.id = ?\n    LIMIT 1\n  ");
  $stmt->execute([$adItemId]);
  $ad = $stmt->fetch(PDO::FETCH_ASSOC);

  if (!$ad || !empty($ad['deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'ad_item_not_found', 'Ad Item not found', 'The ad item does not exist or is soft-deleted.');
    ct_validation_finalize($result);
    return $result;
  }

  if (empty($ad['campaign_exists']) || !empty($ad['campaign_deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'campaign_missing', 'Campaign missing', 'The parent campaign is missing or soft-deleted.');
  }

  $adStatus = (string)($ad['status'] ?? '');
  $campaignStatus = (string)($ad['campaign_status'] ?? '');

  if ($adStatus === 'active' && $campaignStatus !== 'active') {
    ct_validation_add_issue($result, 'error', 'campaign_inactive', 'Campaign not active', 'This ad item is active but the campaign is not active.');
  }

  if (!empty($ad['start_at']) && !empty($ad['end_at']) && strtotime((string)$ad['start_at']) > strtotime((string)$ad['end_at'])) {
    ct_validation_add_issue($result, 'error', 'ad_item_date_range_invalid', 'Ad item schedule invalid', 'Ad item start date occurs after end date.');
  }
  if (!empty($ad['campaign_start_at']) && !empty($ad['campaign_end_at']) && strtotime((string)$ad['campaign_start_at']) > strtotime((string)$ad['campaign_end_at'])) {
    ct_validation_add_issue($result, 'error', 'campaign_date_range_invalid', 'Campaign schedule invalid', 'Campaign start date occurs after end date.');
  }

  if ((string)($ad['type'] ?? '') === 'image' && trim((string)($ad['asset_path'] ?? '')) === '') {
    ct_validation_add_issue($result, 'warning', 'asset_missing', 'Image asset missing', 'Image ad item is missing an uploaded asset path.');
  }

  $target = trim((string)($ad['target'] ?? ''));
  if ($target === '') {
    ct_validation_add_issue($result, 'error', 'target_missing', 'Target URL missing', 'Ad item has no target URL.');
  } elseif (!filter_var($target, FILTER_VALIDATE_URL)) {
    ct_validation_add_issue($result, 'error', 'target_invalid', 'Target URL invalid', 'Ad item target is not a valid URL.');
  } elseif (!domain_allowed($target)) {
    ct_validation_add_issue($result, 'error', 'target_disallowed', 'Target URL blocked', 'Ad item target URL is blocked by domain policy.');
  }

  $zoneStmt = $pdo->prepare("\n    SELECT\n      cz.zone_id, cz.status AS campaign_zone_status,\n      pz.name AS zone_name, pz.status AS zone_status, pz.width, pz.height, pz.deleted_at AS zone_deleted_at\n    FROM campaign_zones cz\n    JOIN partner_zones pz ON pz.id = cz.zone_id\n    WHERE cz.campaign_id = ?\n  ");
  $zoneStmt->execute([(int)($ad['campaign_id'] ?? 0)]);
  $zones = $zoneStmt->fetchAll(PDO::FETCH_ASSOC);

  $activeZones = [];
  foreach ($zones as $zone) {
    if (!empty($zone['zone_deleted_at'])) {
      continue;
    }
    if ((string)($zone['campaign_zone_status'] ?? '') !== 'active') {
      continue;
    }
    if ((string)($zone['zone_status'] ?? '') !== 'active') {
      continue;
    }
    $activeZones[] = $zone;
  }

  if (!$activeZones) {
    ct_validation_add_issue($result, 'error', 'campaign_zone_gap', 'No active zone assignment', 'Campaign has no active zone assignment that can serve this ad item.');
  }

  $compatibleZoneIds = [];
  $zoneMatrix = [];
  foreach ($activeZones as $zone) {
    $zid = (int)$zone['zone_id'];
    $isCompatible = ct_zone_item_compatible($zone, $ad);
    $zoneMatrix[] = [
      'zone_id' => $zid,
      'zone_name' => (string)($zone['zone_name'] ?? ''),
      'zone_width' => (int)($zone['width'] ?? 0),
      'zone_height' => (int)($zone['height'] ?? 0),
      'compatible' => $isCompatible,
    ];

    if ($isCompatible) {
      $compatibleZoneIds[] = (int)$zone['zone_id'];
      continue;
    }

    $zoneName = trim((string)($zone['zone_name'] ?? ''));
    $zoneLabel = $zoneName !== '' ? ($zoneName . ' (#' . $zid . ')') : ('Zone #' . $zid);
    $zoneDim = ((int)($zone['width'] ?? 0) > 0 && (int)($zone['height'] ?? 0) > 0)
      ? ((int)$zone['width'] . 'x' . (int)$zone['height'])
      : 'unset';
    $adDim = ((int)($ad['width'] ?? 0) > 0 && (int)($ad['height'] ?? 0) > 0)
      ? ((int)$ad['width'] . 'x' . (int)$ad['height'])
      : 'unset';

    ct_validation_add_issue(
      $result,
      'warning',
      'size_mismatch',
      'Zone size mismatch',
      $zoneLabel . ' cannot serve this ad item due to size mismatch (zone ' . $zoneDim . ' vs ad ' . $adDim . ').',
      [
        'zone_id' => $zid,
        'zone_name' => (string)($zone['zone_name'] ?? ''),
        'zone_width' => (int)($zone['width'] ?? 0),
        'zone_height' => (int)($zone['height'] ?? 0),
        'ad_width' => (int)($ad['width'] ?? 0),
        'ad_height' => (int)($ad['height'] ?? 0),
      ]
    );
  }

  if ($activeZones && !$compatibleZoneIds) {
    ct_validation_add_issue($result, 'error', 'no_compatible_zone', 'No compatible zone', 'No active zone can serve this ad item with the current dimensions.');
  }

  if (count($activeZones) > 1) {
    $compatibleCount = count($compatibleZoneIds);
    $zoneCount = count($activeZones);
    ct_validation_add_issue(
      $result,
      'info',
      'zone_compatibility_matrix',
      'Campaign has multiple active zones',
      'This campaign has ' . $zoneCount . ' active zones. This ad item is compatible with ' . $compatibleCount . ' of them.',
      [
        'ad_item_id' => $adItemId,
        'ad_width' => (int)($ad['width'] ?? 0),
        'ad_height' => (int)($ad['height'] ?? 0),
        'zones' => $zoneMatrix,
        'compatible_zone_ids' => array_values($compatibleZoneIds),
      ]
    );
  }

  $trkStmt = $pdo->prepare('SELECT zone_id, COUNT(*) AS c FROM tracking_urls WHERE ad_item_id = ? GROUP BY zone_id');
  $trkStmt->execute([$adItemId]);
  $trkRows = $trkStmt->fetchAll(PDO::FETCH_ASSOC);
  $trackingByZone = [];
  foreach ($trkRows as $row) {
    $trackingByZone[(int)($row['zone_id'] ?? 0)] = (int)($row['c'] ?? 0);
  }

  if (!$trkRows) {
    ct_validation_add_issue(
      $result,
      'info',
      'tracking_missing_all',
      'Tracking URL missing',
      'No tracking URL row exists for this ad item. Runtime will auto-heal tracking on first serve/click request when possible.',
      ['auto_heal_runtime' => true]
    );
  }

  foreach ($compatibleZoneIds as $zoneId) {
    if (($trackingByZone[$zoneId] ?? 0) > 0) {
      continue;
    }
    ct_validation_add_issue(
      $result,
      'info',
      'tracking_missing_zone_pair',
      'Missing zone tracking pair',
      'Compatible zone is missing a tracking row for this ad item. Runtime will auto-heal this pair on first serve/click request.',
      ['zone_id' => $zoneId, 'auto_heal_runtime' => true]
    );
  }

  ct_validation_finalize($result);
  return $result;
}
function ct_validate_campaign(PDO $pdo, int $campaignId): array {
  $result = ct_validation_new_result('campaign', $campaignId);

  $stmt = $pdo->prepare("\n    SELECT\n      c.id, c.name, c.status, c.start_at, c.end_at, c.deleted_at,\n      a.id AS advertiser_id, a.name AS advertiser_name, a.deleted_at AS advertiser_deleted_at\n    FROM campaigns c\n    LEFT JOIN advertisers a ON a.id = c.advertiser_id\n    WHERE c.id = ?\n    LIMIT 1\n  ");
  $stmt->execute([$campaignId]);
  $campaign = $stmt->fetch(PDO::FETCH_ASSOC);

  if (!$campaign || !empty($campaign['deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'campaign_not_found', 'Campaign not found', 'Campaign does not exist or is soft-deleted.');
    ct_validation_finalize($result);
    return $result;
  }

  if (empty($campaign['advertiser_id']) || !empty($campaign['advertiser_deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'advertiser_missing', 'Advertiser missing', 'Parent advertiser is missing or soft-deleted.');
  }

  if (!empty($campaign['start_at']) && !empty($campaign['end_at']) && strtotime((string)$campaign['start_at']) > strtotime((string)$campaign['end_at'])) {
    ct_validation_add_issue($result, 'error', 'campaign_date_range_invalid', 'Campaign schedule invalid', 'Campaign start date occurs after end date.');
  }

  $zoneStmt = $pdo->prepare("\n    SELECT\n      cz.zone_id, cz.status AS campaign_zone_status,\n      pz.name AS zone_name, pz.status AS zone_status, pz.width, pz.height, pz.deleted_at AS zone_deleted_at\n    FROM campaign_zones cz\n    JOIN partner_zones pz ON pz.id = cz.zone_id\n    WHERE cz.campaign_id = ?\n  ");
  $zoneStmt->execute([$campaignId]);
  $zones = $zoneStmt->fetchAll(PDO::FETCH_ASSOC);

  $activeZones = [];
  foreach ($zones as $zone) {
    if (!empty($zone['zone_deleted_at'])) {
      continue;
    }
    if ((string)($zone['campaign_zone_status'] ?? '') !== 'active') {
      continue;
    }
    if ((string)($zone['zone_status'] ?? '') !== 'active') {
      continue;
    }
    $activeZones[] = $zone;
  }
  if (!$activeZones) {
    ct_validation_add_issue($result, 'error', 'campaign_has_no_active_zones', 'No active zones', 'Campaign has no active zones assigned.');
  }

  $adStmt = $pdo->prepare("\n    SELECT id, name, status, type, width, height, target, start_at, end_at, updated_at\n    FROM ad_items\n    WHERE campaign_id = ? AND deleted_at IS NULL\n  ");
  $adStmt->execute([$campaignId]);
  $ads = $adStmt->fetchAll(PDO::FETCH_ASSOC);

  $activeAds = [];
  $activeAdsById = [];
  foreach ($ads as $ad) {
    if ((string)($ad['status'] ?? '') !== 'active') {
      continue;
    }
    if ((string)($ad['type'] ?? '') !== 'image') {
      continue;
    }
    $activeAds[] = $ad;
    $activeAdsById[(int)($ad['id'] ?? 0)] = $ad;
  }
  if (!$activeAds) {
    ct_validation_add_issue($result, 'error', 'campaign_has_no_active_ads', 'No active ad items', 'Campaign has no active image ad items.');
  }

  // Campaign cannot PASS when any active ad item is not currently PASS.
  if ($activeAds) {
    $adIds = array_map(static fn(array $ad): int => (int)$ad['id'], $activeAds);
    $latestByAd = [];

    if ($adIds && ct_table_exists($pdo, 'validation_runs')) {
      $ph = implode(',', array_fill(0, count($adIds), '?'));
      $sql = "
        SELECT vr.scope_id AS ad_item_id, vr.status, vr.created_at
        FROM validation_runs vr
        INNER JOIN (
          SELECT scope_id, MAX(id) AS max_id
          FROM validation_runs
          WHERE scope_type = 'ad_item' AND scope_id IN ($ph)
          GROUP BY scope_id
        ) latest ON latest.max_id = vr.id
      ";
      $stLatest = $pdo->prepare($sql);
      $stLatest->execute($adIds);
      foreach ($stLatest->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $latestByAd[(int)($row['ad_item_id'] ?? 0)] = $row;
      }
    }

    $nonPassing = [];
    foreach ($activeAds as $ad) {
      $aid = (int)$ad['id'];
      $latest = $latestByAd[$aid] ?? null;
      $adUpdatedTs = !empty($ad['updated_at']) ? strtotime((string)$ad['updated_at']) : false;

      if (!$latest) {
        $nonPassing[] = ['id' => $aid, 'reason' => 'not_tested'];
        continue;
      }

      $runCreatedTs = !empty($latest['created_at']) ? strtotime((string)$latest['created_at']) : false;
      if ($adUpdatedTs !== false && $runCreatedTs !== false && $runCreatedTs < $adUpdatedTs) {
        $nonPassing[] = ['id' => $aid, 'reason' => 'stale_test'];
        continue;
      }

      if (strtolower((string)($latest['status'] ?? '')) !== 'pass') {
        $nonPassing[] = ['id' => $aid, 'reason' => 'status_' . strtolower((string)($latest['status'] ?? 'unknown'))];
      }
    }

    if ($nonPassing) {
      $parts = [];
      foreach ($nonPassing as $np) {
        $parts[] = (string)$np['id'] . ' (' . (string)$np['reason'] . ')';
      }
      ct_validation_add_issue(
        $result,
        'error',
        'campaign_ad_items_not_pass',
        'Ad item validation not PASS',
        'Campaign cannot PASS because some active ad items are not currently PASS: ' . implode(', ', $parts),
        [
          'ad_items' => $nonPassing,
        ]
      );
    }
  }

  // Campaign-level schedule integrity by ad size: catch same-size overlaps and day gaps.
  $campaignStartTs = null;
  $campaignEndTs = null;
  if (!empty($campaign['start_at'])) {
    $tmp = strtotime((string)$campaign['start_at']);
    if ($tmp !== false) {
      $campaignStartTs = strtotime(date('Y-m-d', $tmp));
    }
  }
  if (!empty($campaign['end_at'])) {
    $tmp = strtotime((string)$campaign['end_at']);
    if ($tmp !== false) {
      $campaignEndTs = strtotime(date('Y-m-d', $tmp));
    }
  }
  $hasCampaignWindow = ($campaignStartTs !== null && $campaignEndTs !== null && $campaignStartTs <= $campaignEndTs);

  $adsBySize = [];
  foreach ($activeAds as $ad) {
    $width = (int)($ad['width'] ?? 0);
    $height = (int)($ad['height'] ?? 0);
    $sizeKey = $width . 'x' . $height;

    $adStartTs = null;
    $adEndTs = null;
    if (!empty($ad['start_at'])) {
      $tmp = strtotime((string)$ad['start_at']);
      if ($tmp !== false) {
        $adStartTs = strtotime(date('Y-m-d', $tmp));
      }
    }
    if (!empty($ad['end_at'])) {
      $tmp = strtotime((string)$ad['end_at']);
      if ($tmp !== false) {
        $adEndTs = strtotime(date('Y-m-d', $tmp));
      }
    }

    if ($adStartTs === null && $campaignStartTs !== null) {
      $adStartTs = $campaignStartTs;
    }
    if ($adEndTs === null && $campaignEndTs !== null) {
      $adEndTs = $campaignEndTs;
    }

    if ($adStartTs === null || $adEndTs === null) {
      continue;
    }

    if ($adStartTs > $adEndTs) {
      ct_validation_add_issue(
        $result,
        'error',
        'ad_item_date_range_invalid',
        'Ad item schedule invalid',
        'An active ad item start date occurs after end date.',
        [
          'ad_item_id' => (int)$ad['id'],
          'ad_item_name' => (string)($ad['name'] ?? ''),
          'size' => $sizeKey,
        ]
      );
      continue;
    }

    if ($campaignStartTs !== null && $adEndTs < $campaignStartTs) {
      continue;
    }
    if ($campaignEndTs !== null && $adStartTs > $campaignEndTs) {
      continue;
    }
    if ($campaignStartTs !== null && $adStartTs < $campaignStartTs) {
      $adStartTs = $campaignStartTs;
    }
    if ($campaignEndTs !== null && $adEndTs > $campaignEndTs) {
      $adEndTs = $campaignEndTs;
    }

    if (!isset($adsBySize[$sizeKey])) {
      $adsBySize[$sizeKey] = [];
    }
    $adsBySize[$sizeKey][] = [
      'ad_item_id' => (int)$ad['id'],
      'ad_item_name' => (string)($ad['name'] ?? ''),
      'size' => $sizeKey,
      'start_ts' => $adStartTs,
      'end_ts' => $adEndTs,
    ];
  }

  $scheduleFindings = [];
  $scheduleAdIds = [];
  foreach ($adsBySize as $sizeKey => $intervals) {
    if (!$intervals || count($intervals) < 2) {
      continue;
    }

    usort($intervals, static function(array $a, array $b): int {
      if ((int)$a['start_ts'] === (int)$b['start_ts']) {
        return (int)$a['end_ts'] <=> (int)$b['end_ts'];
      }
      return (int)$a['start_ts'] <=> (int)$b['start_ts'];
    });

    $merged = [];
    $current = $intervals[0];
    $current['source_ids'] = [(int)$current['ad_item_id']];
    $total = count($intervals);
    for ($i = 1; $i < $total; $i++) {
      $next = $intervals[$i];
      $nextStart = (int)$next['start_ts'];
      $nextEnd = (int)$next['end_ts'];
      $currentEnd = (int)$current['end_ts'];
      $currentEndPlusOne = strtotime('+1 day', $currentEnd);

      if ($nextStart <= $currentEnd) {
        $aId = (int)$current['ad_item_id'];
        $bId = (int)$next['ad_item_id'];
        $scheduleAdIds[$aId] = true;
        $scheduleAdIds[$bId] = true;
        $scheduleFindings[] = 'size ' . $sizeKey . ' overlap near ' . date('Y-m-d', $nextStart) . ' (ad IDs ' . $aId . ' and ' . $bId . ')';
      }

      if ($nextStart <= $currentEndPlusOne) {
        if ($nextEnd > $currentEnd) {
          $current['end_ts'] = $nextEnd;
        }
        $current['source_ids'][] = (int)$next['ad_item_id'];
        continue;
      }

      $merged[] = $current;
      $current = $next;
      $current['source_ids'] = [(int)$current['ad_item_id']];
    }
    $merged[] = $current;

    // Only flag internal gaps between consecutive same-size segments.
    $mergedCount = count($merged);
    for ($mi = 1; $mi < $mergedCount; $mi++) {
      $prevSegment = $merged[$mi - 1];
      $segment = $merged[$mi];
      $prevEndPlusOne = strtotime('+1 day', (int)$prevSegment['end_ts']);
      $segmentStart = (int)$segment['start_ts'];
      if ($segmentStart <= $prevEndPlusOne) {
        continue;
      }

      $gapStart = $prevEndPlusOne;
      $gapEnd = strtotime('-1 day', $segmentStart);
      $related = [];
      if (!empty($prevSegment['source_ids']) && is_array($prevSegment['source_ids'])) {
        foreach ($prevSegment['source_ids'] as $rid) {
          $related[(int)$rid] = true;
        }
      }
      if (!empty($segment['source_ids']) && is_array($segment['source_ids'])) {
        foreach ($segment['source_ids'] as $rid) {
          $related[(int)$rid] = true;
        }
      }

      $relatedIds = array_keys($related);
      sort($relatedIds, SORT_NUMERIC);
      foreach ($relatedIds as $rid) {
        $scheduleAdIds[(int)$rid] = true;
      }

      $scheduleFindings[] = 'size ' . $sizeKey . ' gap ' . date('Y-m-d', $gapStart) . ' to ' . date('Y-m-d', $gapEnd) . ' (ad IDs ' . implode(', ', $relatedIds) . ')';
    }
  }

  if ($scheduleFindings) {
    $adIds = array_keys($scheduleAdIds);
    sort($adIds, SORT_NUMERIC);
    $detail = 'Campaign schedule continuity failed. Related ad item IDs: ' . implode(', ', $adIds) . '. Findings: ' . implode(' | ', $scheduleFindings);
    ct_validation_add_issue(
      $result,
      'error',
      'campaign_ad_schedule_gap',
      'Ad schedule gap',
      $detail,
      [
        'ad_item_ids' => $adIds,
        'findings' => $scheduleFindings,
      ]
    );
  }

  $compatByAd = [];
  $compatByZone = [];
  foreach ($activeAds as $ad) {
    $aid = (int)$ad['id'];
    $compatByAd[$aid] = [];
    foreach ($activeZones as $zone) {
      $zid = (int)$zone['zone_id'];
      if (!ct_zone_item_compatible($zone, $ad)) {
        continue;
      }
      $compatByAd[$aid][] = $zid;
      if (!isset($compatByZone[$zid])) {
        $compatByZone[$zid] = [];
      }
      $compatByZone[$zid][] = $aid;
    }

    if (!$compatByAd[$aid] && $activeZones) {
      ct_validation_add_issue(
        $result,
        'error',
        'ad_zone_compatibility_gap',
        'Ad item has no compatible zone',
        'Active ad item cannot be served by any active zone in this campaign.',
        ['ad_item_id' => $aid, 'ad_item_name' => (string)($ad['name'] ?? '')]
      );
    }

    $target = trim((string)($ad['target'] ?? ''));
    if ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !domain_allowed($target)) {
      ct_validation_add_issue(
        $result,
        'warning',
        'ad_target_invalid',
        'Ad item target needs attention',
        'One active ad item has a missing or blocked target URL.',
        ['ad_item_id' => $aid, 'ad_item_name' => (string)($ad['name'] ?? '')]
      );
    }
  }

  foreach ($activeZones as $zone) {
    $zid = (int)$zone['zone_id'];
    if (!empty($compatByZone[$zid])) {
      continue;
    }
    ct_validation_add_issue(
      $result,
      'error',
      'zone_has_no_compatible_ads',
      'Zone has no compatible ad items',
      'At least one active zone has no compatible active ad items in this campaign.',
      ['zone_id' => $zid, 'zone_name' => (string)($zone['zone_name'] ?? '')]
    );
  }

  $nowTs = time();
  $campaignStartTs = !empty($campaign['start_at']) ? strtotime((string)$campaign['start_at']) : null;
  $campaignEndTs = !empty($campaign['end_at']) ? strtotime((string)$campaign['end_at']) : null;

  $adScheduleWindow = static function(array $ad, ?int $campaignStartTs, ?int $campaignEndTs): array {
    $adStartTs = !empty($ad['start_at']) ? strtotime((string)$ad['start_at']) : null;
    $adEndTs = !empty($ad['end_at']) ? strtotime((string)$ad['end_at']) : null;

    $effectiveStart = $adStartTs;
    if ($campaignStartTs !== null) {
      $effectiveStart = $effectiveStart === null ? $campaignStartTs : max($effectiveStart, $campaignStartTs);
    }

    $effectiveEnd = $adEndTs;
    if ($campaignEndTs !== null) {
      $effectiveEnd = $effectiveEnd === null ? $campaignEndTs : min($effectiveEnd, $campaignEndTs);
    }

    return [$effectiveStart, $effectiveEnd];
  };

  foreach ($activeZones as $zone) {
    $zid = (int)$zone['zone_id'];
    $compatibleAdIds = $compatByZone[$zid] ?? [];
    if (!$compatibleAdIds) {
      continue;
    }

    $servableNow = 0;
    $pendingFuture = [];

    foreach ($compatibleAdIds as $aid) {
      $ad = $activeAdsById[(int)$aid] ?? null;
      if (!$ad) {
        continue;
      }

      [$effectiveStart, $effectiveEnd] = $adScheduleWindow($ad, $campaignStartTs, $campaignEndTs);

      if ($effectiveStart !== null && $effectiveStart > $nowTs) {
        $pendingFuture[] = [
          'ad_item_id' => (int)$ad['id'],
          'ad_item_name' => (string)($ad['name'] ?? ''),
          'scheduled_start_at' => date('Y-m-d H:i:s', $effectiveStart),
        ];
        continue;
      }

      if ($effectiveEnd !== null && $effectiveEnd < $nowTs) {
        continue;
      }

      $servableNow++;
    }

    if ($servableNow > 0) {
      continue;
    }

    if ($pendingFuture) {
      usort($pendingFuture, static function(array $a, array $b): int {
        return strcmp((string)$a['scheduled_start_at'], (string)$b['scheduled_start_at']);
      });
      $firstPending = $pendingFuture[0] ?? [];
      $earliest = $pendingFuture[0]['scheduled_start_at'] ?? null;
      $adItemId = (int)($firstPending['ad_item_id'] ?? 0);
      $adItemName = trim((string)($firstPending['ad_item_name'] ?? ''));
      $adItemLabel = $adItemName !== ''
        ? ('Ad Item #' . $adItemId . ' (' . $adItemName . ')')
        : ('Ad Item #' . $adItemId);

      ct_validation_add_issue(
        $result,
        'error',
        'campaign_future_scheduled_ad_pending',
        'Zone not currently serving (future schedule)',
        'Active zone has no currently serveable ad item. ' . $adItemLabel . ' is scheduled to start on ' . (string)$earliest . '.',
        [
          'zone_id' => $zid,
          'zone_name' => (string)($zone['zone_name'] ?? ''),
          'trigger_ad_item_id' => $adItemId,
          'trigger_ad_item_name' => $adItemName,
          'pending_ad_items' => $pendingFuture,
          'earliest_start_at' => $earliest,
        ]
      );
      continue;
    }

    ct_validation_add_issue(
      $result,
      'error',
      'zone_has_no_currently_servable_ads',
      'Zone not currently serving',
      'Active zone has no ad item currently within its live schedule window.',
      ['zone_id' => $zid, 'zone_name' => (string)($zone['zone_name'] ?? '')]
    );
  }

  $trackingByPair = [];
  if ($activeAds) {
    $ids = array_map(static fn(array $ad): int => (int)$ad['id'], $activeAds);
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $trkStmt = $pdo->prepare("SELECT ad_item_id, zone_id, COUNT(*) AS c FROM tracking_urls WHERE ad_item_id IN ($placeholders) GROUP BY ad_item_id, zone_id");
    $trkStmt->execute($ids);
    foreach ($trkStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
      $k = ((int)$row['ad_item_id']) . ':' . ((int)$row['zone_id']);
      $trackingByPair[$k] = (int)($row['c'] ?? 0);
    }
  }

  foreach ($compatByAd as $adId => $zoneIds) {
    foreach ($zoneIds as $zoneId) {
      $k = $adId . ':' . $zoneId;
      if (($trackingByPair[$k] ?? 0) > 0) {
        continue;
      }
      ct_validation_add_issue(
        $result,
        'info',
        'tracking_pair_missing',
        'Missing tracking pair',
        'A compatible ad item/zone pair is missing a tracking URL record. Runtime will auto-heal this pair on first serve/click request.',
        ['ad_item_id' => $adId, 'zone_id' => $zoneId, 'auto_heal_runtime' => true]
      );
    }
  }

  ct_validation_finalize($result);
  return $result;
}
function ct_validate_zone(PDO $pdo, int $zoneId): array {
  $result = ct_validation_new_result('zone', $zoneId);

  $stmt = $pdo->prepare("\n    SELECT\n      pz.id, pz.partner_id, pz.name, pz.type, pz.width, pz.height, pz.status, pz.deleted_at,\n      p.name AS partner_name, p.deleted_at AS partner_deleted_at\n    FROM partner_zones pz\n    LEFT JOIN partners p ON p.id = pz.partner_id\n    WHERE pz.id = ?\n    LIMIT 1\n  ");
  $stmt->execute([$zoneId]);
  $zone = $stmt->fetch(PDO::FETCH_ASSOC);

  if (!$zone || !empty($zone['deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'zone_not_found', 'Zone not found', 'Zone does not exist or is soft-deleted.');
    ct_validation_finalize($result);
    return $result;
  }

  if (empty($zone['partner_id']) || !empty($zone['partner_deleted_at'])) {
    ct_validation_add_issue($result, 'error', 'partner_missing', 'Partner missing', 'Parent partner is missing or soft-deleted.');
  }

  $zoneType = (string)($zone['type'] ?? '');
  $zw = (int)($zone['width'] ?? 0);
  $zh = (int)($zone['height'] ?? 0);
  if (($zoneType === 'email' || $zoneType === 'website') && ($zw <= 0 || $zh <= 0)) {
    ct_validation_add_issue($result, 'warning', 'zone_size_unset', 'Zone size not set', 'Zone width/height are not set; size-specific serving checks are limited.');
  }

  $campStmt = $pdo->prepare("\n    SELECT\n      c.id AS campaign_id, c.name AS campaign_name, c.status AS campaign_status, c.deleted_at AS campaign_deleted_at,\n      cz.status AS campaign_zone_status\n    FROM campaign_zones cz\n    JOIN campaigns c ON c.id = cz.campaign_id\n    WHERE cz.zone_id = ?\n  ");
  $campStmt->execute([$zoneId]);
  $campaignRows = $campStmt->fetchAll(PDO::FETCH_ASSOC);

  $activeCampaigns = [];
  foreach ($campaignRows as $campaign) {
    if (!empty($campaign['campaign_deleted_at'])) {
      continue;
    }
    if ((string)($campaign['campaign_zone_status'] ?? '') !== 'active') {
      continue;
    }
    if ((string)($campaign['campaign_status'] ?? '') !== 'active') {
      continue;
    }
    $activeCampaigns[] = $campaign;
  }

  if (!$activeCampaigns) {
    ct_validation_add_issue($result, 'error', 'zone_has_no_active_campaigns', 'No active campaigns', 'Zone has no active campaign assignments.');
  }

  $trackingByAd = [];
  $trkStmt = $pdo->prepare('SELECT ad_item_id, COUNT(*) AS c FROM tracking_urls WHERE zone_id = ? AND ad_item_id IS NOT NULL GROUP BY ad_item_id');
  $trkStmt->execute([$zoneId]);
  foreach ($trkStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
    $trackingByAd[(int)$row['ad_item_id']] = (int)($row['c'] ?? 0);
  }

  foreach ($activeCampaigns as $campaign) {
    $cid = (int)$campaign['campaign_id'];

    $adStmt = $pdo->prepare("\n      SELECT id, name, width, height, type, status\n      FROM ad_items\n      WHERE campaign_id = ?\n        AND deleted_at IS NULL\n        AND status = 'active'\n        AND type = 'image'\n    ");
    $adStmt->execute([$cid]);
    $ads = $adStmt->fetchAll(PDO::FETCH_ASSOC);

    $compatibleAds = [];
    foreach ($ads as $ad) {
      if (ct_zone_item_compatible($zone, $ad)) {
        $compatibleAds[] = $ad;
      }
    }

    if (!$compatibleAds) {
      ct_validation_add_issue(
        $result,
        'error',
        'campaign_has_no_zone_compatible_ads',
        'Campaign mismatch for zone',
        'Active campaign has no ad item compatible with this zone.',
        ['campaign_id' => $cid, 'campaign_name' => (string)($campaign['campaign_name'] ?? '')]
      );
      continue;
    }

    foreach ($compatibleAds as $ad) {
      $aid = (int)$ad['id'];
      if (($trackingByAd[$aid] ?? 0) > 0) {
        continue;
      }
      ct_validation_add_issue(
        $result,
        'info',
        'zone_tracking_missing',
        'Missing zone tracking row',
        'Compatible ad item is missing a tracking row for this zone. Runtime will auto-heal this pair on first serve/click request.',
        [
          'campaign_id' => $cid,
          'ad_item_id' => $aid,
          'ad_item_name' => (string)($ad['name'] ?? ''),
          'auto_heal_runtime' => true,
        ]
      );
    }
  }

  ct_validation_finalize($result);
  return $result;
}

function ct_validation_can_confirm_size_mismatch(array $result): bool {
  if ((string)($result['scope_type'] ?? '') !== 'ad_item') {
    return false;
  }

  $issues = is_array($result['issues'] ?? null) ? $result['issues'] : [];
  $hasErrors = false;
  $hasSizeMismatchWarning = false;
  $blockingWarnings = 0;

  foreach ($issues as $issue) {
    $severity = (string)($issue['severity'] ?? '');
    $code = (string)($issue['code'] ?? '');
    if ($severity === 'error') {
      $hasErrors = true;
    }
    if ($severity === 'warning' && $code === 'size_mismatch') {
      $hasSizeMismatchWarning = true;
    }
    if ($severity === 'warning' && $code !== 'size_mismatch' && !str_starts_with($code, 'tracking_')) {
      $blockingWarnings++;
    }
  }

  $matrix = is_array($result['summary']['compatibility_matrix'] ?? null)
    ? $result['summary']['compatibility_matrix']
    : [];
  $allCompatible = true;
  foreach ($matrix as $row) {
    if (strtoupper((string)($row['status'] ?? '')) !== 'MATCH') {
      $allCompatible = false;
      break;
    }
  }

  return $hasSizeMismatchWarning && !$hasErrors && $blockingWarnings === 0 && $allCompatible;
}

function ct_validation_can_confirm_campaign_schedule(array $result): bool {
  if ((string)($result['scope_type'] ?? '') !== 'campaign') {
    return false;
  }

  $confirmed = is_array($result['confirmed_override'] ?? null) ? $result['confirmed_override'] : [];
  if ((string)($confirmed['type'] ?? '') === 'campaign_schedule_ack') {
    return false;
  }

  $issues = is_array($result['issues'] ?? null) ? $result['issues'] : [];
  $hasCampaignScheduleIssue = false;
  $otherErrors = 0;

  foreach ($issues as $issue) {
    if ((string)($issue['severity'] ?? '') !== 'error') {
      continue;
    }

    $code = (string)($issue['code'] ?? '');
    if ($code === 'campaign_future_scheduled_ad_pending') {
      $hasCampaignScheduleIssue = true;
      continue;
    }
    $otherErrors++;
  }

  return $hasCampaignScheduleIssue && $otherErrors === 0;
}

function ct_validation_apply_size_mismatch_confirm(array $result, array $auth): array {
  $issues = is_array($result['issues'] ?? null) ? $result['issues'] : [];
  foreach ($issues as &$issue) {
    if ((string)($issue['severity'] ?? '') === 'warning' && (string)($issue['code'] ?? '') === 'size_mismatch') {
      $issue['severity'] = 'info';
      $issue['meta'] = is_array($issue['meta'] ?? null) ? $issue['meta'] : [];
      $issue['meta']['confirmed_override'] = true;
    }
  }
  unset($issue);

  $errorCount = 0;
  $warningCount = 0;
  $infoCount = 0;
  foreach ($issues as $issue) {
    $sev = (string)($issue['severity'] ?? 'info');
    if ($sev === 'error') {
      $errorCount++;
    } elseif ($sev === 'warning') {
      $warningCount++;
    } else {
      $infoCount++;
    }
  }

  $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];
  $summary['error_count'] = $errorCount;
  $summary['warning_count'] = $warningCount;
  $summary['info_count'] = $infoCount;
  $summary['confirmed_override'] = [
    'type' => 'size_mismatch_ack',
    'confirmed_at' => gmdate('c'),
    'by_user_id' => (int)($auth['id'] ?? 0),
    'by_email' => (string)($auth['email'] ?? ''),
  ];

  $result['issues'] = $issues;
  $result['summary'] = $summary;
  $result['confirmed_override'] = $summary['confirmed_override'];
  if ($errorCount > 0) {
    $result['status'] = 'error';
  } elseif ($warningCount > 0) {
    $result['status'] = 'warn';
  } else {
    $result['status'] = 'pass';
  }

  return $result;
}

function ct_validation_apply_campaign_schedule_confirm(array $result, array $auth): array {
  $issues = is_array($result['issues'] ?? null) ? $result['issues'] : [];
  foreach ($issues as &$issue) {
    if ((string)($issue['severity'] ?? '') === 'error' && (string)($issue['code'] ?? '') === 'campaign_future_scheduled_ad_pending') {
      $issue['severity'] = 'info';
      $issue['meta'] = is_array($issue['meta'] ?? null) ? $issue['meta'] : [];
      $issue['meta']['confirmed_override'] = true;
    }
  }
  unset($issue);

  $errorCount = 0;
  $warningCount = 0;
  $infoCount = 0;
  foreach ($issues as $issue) {
    $sev = (string)($issue['severity'] ?? 'info');
    if ($sev === 'error') {
      $errorCount++;
    } elseif ($sev === 'warning') {
      $warningCount++;
    } else {
      $infoCount++;
    }
  }

  $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];
  $summary['error_count'] = $errorCount;
  $summary['warning_count'] = $warningCount;
  $summary['info_count'] = $infoCount;
  $summary['confirmed_override'] = [
    'type' => 'campaign_schedule_ack',
    'confirmed_at' => gmdate('c'),
    'by_user_id' => (int)($auth['id'] ?? 0),
    'by_email' => (string)($auth['email'] ?? ''),
  ];

  $result['issues'] = $issues;
  $result['summary'] = $summary;
  $result['confirmed_override'] = $summary['confirmed_override'];
  if ($errorCount > 0) {
    $result['status'] = 'fail';
  } elseif ($warningCount > 0) {
    $result['status'] = 'warn';
  } else {
    $result['status'] = 'pass';
  }

  return $result;
}

function ct_validation_log_run(PDO $pdo, array $result, array $auth): array {
  if (!ct_table_exists($pdo, 'validation_runs') || !ct_table_exists($pdo, 'validation_run_issues')) {
    return ['logged' => false, 'run_id' => null, 'reason' => 'validation_tables_missing'];
  }

  $scopeType = (string)($result['scope_type'] ?? '');
  $scopeId = (int)($result['scope_id'] ?? 0);
  $status = (string)($result['status'] ?? 'pass');
  $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];
  $issues = is_array($result['issues'] ?? null) ? $result['issues'] : [];

  $pdo->beginTransaction();
  try {
    $insertRun = $pdo->prepare("\n      INSERT INTO validation_runs\n        (scope_type, scope_id, status, error_count, warning_count, info_count, summary_json, issues_json, tested_by_user_id, tested_by_email, created_at)\n      VALUES\n        (:scope_type, :scope_id, :status, :error_count, :warning_count, :info_count, :summary_json, :issues_json, :tested_by_user_id, :tested_by_email, NOW())\n    ");
    $insertRun->execute([
      ':scope_type' => $scopeType,
      ':scope_id' => $scopeId,
      ':status' => $status,
      ':error_count' => (int)($summary['error_count'] ?? 0),
      ':warning_count' => (int)($summary['warning_count'] ?? 0),
      ':info_count' => (int)($summary['info_count'] ?? 0),
      ':summary_json' => json_encode($summary, JSON_UNESCAPED_SLASHES),
      ':issues_json' => json_encode($issues, JSON_UNESCAPED_SLASHES),
      ':tested_by_user_id' => (int)($auth['id'] ?? 0),
      ':tested_by_email' => (string)($auth['email'] ?? ''),
    ]);

    $runId = (int)$pdo->lastInsertId();

    if ($issues) {
      $insertIssue = $pdo->prepare("\n        INSERT INTO validation_run_issues\n          (validation_run_id, severity, code, title, detail, meta_json, created_at)\n        VALUES\n          (:validation_run_id, :severity, :code, :title, :detail, :meta_json, NOW())\n      ");

      foreach ($issues as $issue) {
        $insertIssue->execute([
          ':validation_run_id' => $runId,
          ':severity' => (string)($issue['severity'] ?? 'info'),
          ':code' => (string)($issue['code'] ?? 'unknown_issue'),
          ':title' => (string)($issue['title'] ?? 'Issue'),
          ':detail' => (string)($issue['detail'] ?? ''),
          ':meta_json' => json_encode(($issue['meta'] ?? []), JSON_UNESCAPED_SLASHES),
        ]);
      }
    }

    $pdo->commit();
    return ['logged' => true, 'run_id' => $runId, 'reason' => null];
  } catch (Throwable $e) {
    $pdo->rollBack();
    error_log('Validation log write failed: ' . $e->getMessage());
    return ['logged' => false, 'run_id' => null, 'reason' => 'write_failed'];
  }
}

// ---------- Routing ----------
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';

// DEBUG: Log all requests to diagnose routing
error_log("ROUTE DEBUG: method=$method, uri=$uri");

// ===== IP Blocking Middleware =====
// Check if client IP is blocked (except for /health monitoring endpoint)
if ($uri !== '/health') {
  $clientIp = $_SERVER['REMOTE_ADDR'] ?? '';
  
  if ($clientIp) {
    $blockInfo = IpBlockManager::isBlocked($clientIp);

    if ($blockInfo) {
      try {
        $pdo = db();
        if (bot_whitelist_is_active_for_ip($pdo, $clientIp)) {
          $blockInfo = false;
        }
      } catch (Throwable $_) {
        // Keep normal block enforcement on DB/query failures.
      }
    }
    
    if ($blockInfo) {
      // IP is blocked - deny access
      http_response_code(403);
      header('Content-Type: application/json; charset=utf-8');
      
      $isPermanent = empty($blockInfo['blocked_until']);
      $expiresAt = $isPermanent ? 'Never' : $blockInfo['blocked_until'];
      
      echo json_encode([
        'error' => 'Access Denied',
        'message' => 'Your IP address has been blocked.',
        'ip' => $clientIp,
        'reason' => $blockInfo['reason'] ?? 'Security policy violation',
        'blocked_until' => $expiresAt,
        'contact' => 'If you believe this is an error, please contact support.'
      ]);
      
      // Log the blocked attempt
      error_log("IP_BLOCK: Blocked access attempt from {$clientIp} to {$uri}");
      
      exit;
    }
  }
}

// Health
if ($uri === '/') { 
  text("ClickTrack+ alive (v0.2.0)\n", 200); 
}
if ($uri === '/health') {
  $pdo = db();
  $since = (new DateTimeImmutable('-24 hours'))->format('Y-m-d H:i:s');
  $clicks = $pdo->prepare('SELECT COUNT(*) c FROM click_logs WHERE ts >= ?');
  $imps   = $pdo->prepare('SELECT COUNT(*) c FROM impression_logs WHERE ts >= ?');
  $clicks->execute([$since]); $imps->execute([$since]);
  json([
    'version' => '0.2.0',
    'since' => $since,
    'clicks_last_24h' => (int)$clicks->fetch()['c'],
    'impressions_last_24h' => (int)$imps->fetch()['c'],
  ]);
}

// Serve Ad Item (public endpoint for iframe embed with click/impression tracking)
if ($method === 'GET' && preg_match('#^/serve/ad/([0-9]+)$#', $uri, $m)) {
  error_log("ROUTE DEBUG: /serve/ad/ route MATCHED for ad item " . $m[1]);
  $adItemId = (int)$m[1];
  
  try {
    $pdo = db();
    $st = $pdo->prepare("
      SELECT id, name, type, status, target, width, height, asset_path, asset_mime
      FROM ad_items
      WHERE id = ?
      LIMIT 1
    ");
    $st->execute([$adItemId]);
    $ad = $st->fetch(PDO::FETCH_ASSOC);
    
    if (!$ad) {
      http_response_code(404);
      echo '<!DOCTYPE html><html><body style="margin:0;padding:20px;font-family:sans-serif;text-align:center;color:#666;">Ad not found</body></html>';
      exit;
    }
    
    // Resolve or create tracking URL for click tracking
    $trackingUrlId = null;
    $targetUrl = trim((string)($ad['target'] ?? ''));
    
    if ($targetUrl !== '') {
      // Try to find existing tracking URL
      $stTrack = $pdo->prepare('SELECT id FROM tracking_urls WHERE ad_item_id = ? ORDER BY id DESC LIMIT 1');
      $stTrack->execute([$adItemId]);
      $trackRow = $stTrack->fetch(PDO::FETCH_ASSOC);
      
      if ($trackRow && isset($trackRow['id'])) {
        $trackingUrlId = (int)$trackRow['id'];
      } else {
        // No tracking URL exists - create one with NULL UTM values
        $destCol = dest_col();
        $colsMap = tracking_columns();
        $hasAdCol = isset($colsMap['ad_item_id']);
        
        if ($destCol && $hasAdCol && domain_allowed($targetUrl)) {
          try {
            $fields = [$destCol, 'ad_item_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_policy', 'created_at'];
            $marks = [':dest', ':ad', ':us', ':um', ':ucp', ':uco', ':up', 'NOW()'];
            $params = [
              ':dest' => $targetUrl,
              ':ad' => $adItemId,
              ':us' => null,
              ':um' => null,
              ':ucp' => null,
              ':uco' => null,
              ':up' => 'append_if_missing'
            ];
            
            $sql = 'INSERT INTO tracking_urls (' . implode(',', $fields) . ') VALUES (' . implode(',', $marks) . ')';
            $stInsert = $pdo->prepare($sql);
            $stInsert->execute($params);
            $trackingUrlId = (int)$pdo->lastInsertId();
          } catch (Throwable $e) {
            error_log("Failed to create tracking URL for ad item {$adItemId}: " . $e->getMessage());
          }
        }
      }
    }
    
    // Log impression if we have a tracking URL
    if ($trackingUrlId) {
      try {
        // Bot detection - load custom config for this zone
        $botDetector = get_bot_detector($pdo, $trackingUrlId);
        
        $requestData = [
          'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
          'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
          'referrer' => $_SERVER['HTTP_REFERER'] ?? null,
          'host' => $_SERVER['HTTP_HOST'] ?? null,
          'http_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET'
        ];
        
        $botCheck = $botDetector->checkRequest('impression', $trackingUrlId, $requestData);
        
        if ($botCheck['allowed']) {
          $stImp = $pdo->prepare('
            INSERT INTO impression_logs (tracking_url_id, ip, user_agent, referrer, host, http_method)
            VALUES (?, ?, ?, ?, ?, ?)
          ');
          $stImp->execute([
            $trackingUrlId,
            $requestData['ip'],
            $requestData['user_agent'],
            $requestData['referrer'],
            $requestData['host'],
            $requestData['http_method']
          ]);
        } else {
          // Log blocked request for analysis
          $botDetector->logBlocked('impression', $trackingUrlId, $requestData, $botCheck);
          error_log("Impression blocked: {$botCheck['reason']} (score: {$botCheck['score']})");
        }
      } catch (Throwable $e) {
        // Swallow logging errors - don't fail the ad serving
        error_log("Failed to log impression for ad item {$adItemId}: " . $e->getMessage());
      }
    }
    
    // Build the HTML for the ad
    header('Content-Type: text/html; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    // CRITICAL: Allow embedding on partner sites - CSP without frame-ancestors
    header("Content-Security-Policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");
    
    $assetPath = htmlspecialchars($ad['asset_path'] ?? '', ENT_QUOTES, 'UTF-8');
    $width = $ad['width'] ?? 300;
    $height = $ad['height'] ?? 250;
    $name = htmlspecialchars($ad['name'] ?? '', ENT_QUOTES, 'UTF-8');
    
    // Use tracking URL for click tracking
    $clickUrl = $trackingUrlId ? '/r/' . $trackingUrlId : '#';
    
    echo '<!DOCTYPE html>';
    echo '<html>';
    echo '<head>';
    echo '<meta charset="utf-8">';
    echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
    echo '<style>body{margin:0;padding:0;overflow:hidden;}a{display:block;text-decoration:none;}img{display:block;max-width:100%;height:auto;}</style>';
    echo '</head>';
    echo '<body>';
    
    if ($trackingUrlId) {
      echo '<a href="' . htmlspecialchars($clickUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener">';
    }
    
    if ($assetPath) {
      echo '<img src="' . $assetPath . '" alt="' . $name . '" width="' . $width . '" height="' . $height . '">';
    } else {
      echo '<div style="width:' . $width . 'px;height:' . $height . 'px;display:flex;align-items:center;justify-content:center;background:#f0f0f0;color:#999;">' . $name . '</div>';
    }
    
    if ($trackingUrlId) {
      echo '</a>';
    }
    
    echo '</body>';
    echo '</html>';
    error_log("ROUTE DEBUG: /serve/ad/ route completed, sending response");
    exit;
  } catch (Throwable $e) {
    http_response_code(500);
    echo '<!DOCTYPE html><html><body style="margin:0;padding:20px;font-family:sans-serif;text-align:center;color:#666;">Error loading ad</body></html>';
    exit;
  }
}

  // ==== PUBLIC TAG ENDPOINTS (Wave 3: read-only; gated by CT_ENABLE_TAGS) ====
  // Zone tag (website iframe or newsletter-safe <a><img>)
  if ($method === 'GET' && preg_match('#^/tag/zone/([0-9]+)\.html$#', $uri, $m)) {
    $tags_on = (($_ENV['CT_ENABLE_TAGS'] ?? '0') === '1');
    if (!$tags_on) { http_response_code(404); echo 'Not found'; exit; }

    $zoneId = (int)$m[1];
    $fmt = strtolower((string)($_GET['format'] ?? 'website')); // 'newsletter' | 'website'
    // TODO (later wave): derive width/height from zone; default to 600x90 for now
    $w = 600; $h = 90;

    header('Content-Type: text/html; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    // Zone tags must be embeddable in iframes/emails on external sites
    header("Content-Security-Policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");

    if ($fmt === 'newsletter') {
      // Email-safe snippet: <a><img></a> (no JS, no iframe)
      echo "<!-- ClickTrack+ Zone Tag (newsletter) -->\n";
      echo "<div align=\"center\"><a href=\"/engine.php/click/zone/{$zoneId}?uid={{uid}}&cb={{cb}}\" target=\"_blank\" rel=\"noopener\">";
      echo "<img src=\"/engine.php/render/zone/{$zoneId}?uid={{uid}}&cb={{cb}}\" width=\"{$w}\" height=\"{$h}\" alt=\"Ad\"></a></div>\n";
      exit;
    }


    // Website (iframe) snippet: defers real rendering to /serve (Wave 4)
    $qs = [];
    if (isset($_GET['uid'])   && $_GET['uid']   !== '') { $qs['uid']   = (string)$_GET['uid']; }
    if (isset($_GET['sizes']) && $_GET['sizes'] !== '') { $qs['sizes'] = (string)$_GET['sizes']; }
    $query = $qs ? ('?' . http_build_query($qs)) : '';

    echo "<!-- ClickTrack+ Zone Tag (website iframe) -->\n";
    echo "<iframe src=\"/engine.php/serve/zone/{$zoneId}{$query}\" frameborder=\"0\" scrolling=\"no\" style=\"border:0;width:100%;height:0;\"></iframe>\n";
    exit;
  }

  // Ad Item tag (direct <a><img>) — useful when platforms don’t accept zone tags
  if ($method === 'GET' && preg_match('#^/tag/item/([0-9]+)\.html$#', $uri, $m)) {
    $tags_on = (($_ENV['CT_ENABLE_TAGS'] ?? '0') === '1');
    if (!$tags_on) { http_response_code(404); echo 'Not found'; exit; }

    $itemId = (int)$m[1];
    // TODO (later wave): derive width/height from item; fallback 600x90 now
    $w = 600; $h = 90;

    header('Content-Type: text/html; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    // Ad item tags must be embeddable in iframes on external sites
    header("Content-Security-Policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");
    echo "<!-- ClickTrack+ Ad Item Tag -->\n";
    echo "<div align=\"center\"><a href=\"/engine.php/click/item/{$itemId}?rid={{rid}}\" target=\"_blank\" rel=\"noopener\">";
    echo "<img src=\"/engine.php/render/item/{$itemId}?rid={{rid}}\" width=\"{$w}\" height=\"{$h}\" alt=\"Ad\" style=\"display:block;border:0;outline:0;text-decoration:none\"></a></div>\n";
    exit;
  }
  // ==== END PUBLIC TAG ENDPOINTS ====

  // ==== TICKETS API (Session Auth) ====
  if ($uri === '/tickets' || preg_match('#^/tickets/([0-9]+)(/comments)?$#', $uri, $tm)) {
    try {
      $auth = require_session_auth(['employee','admin','superadmin']);
      $pdo = db();

      $ticketTypes = ['bug','feature','task','question','other'];
      $ticketPriorities = ['low','medium','high','urgent'];
      $ticketStatuses = ['open','in_progress','blocked','resolved','closed'];

      $ready = tickets_table_ready($pdo);
      if (!$ready) {
        if ($method === 'GET' && $uri === '/tickets') {
          json([
            'ok' => true,
            'ready' => false,
            'items' => [],
            'total' => 0,
            'limit' => 200,
            'offset' => 0
          ]);
        }
        json(['error' => 'tickets_not_ready'], 503);
      }

      if ($method === 'GET' && $uri === '/tickets') {
        $q = trim((string)($_GET['q'] ?? ''));
        $status = (string)($_GET['status'] ?? '');
        $type = (string)($_GET['type'] ?? '');
        $priority = (string)($_GET['priority'] ?? '');
        $limit = (int)($_GET['limit'] ?? 200);
        $offset = (int)($_GET['offset'] ?? 0);

        if ($limit < 1) $limit = 1;
        if ($limit > 200) $limit = 200;
        if ($offset < 0) $offset = 0;

        $where = [];
        $params = [];
        if ($q !== '') {
          $where[] = '(t.title LIKE ? OR t.description LIKE ? OR t.tags LIKE ?)';
          $like = '%'.$q.'%';
          $params[] = $like; $params[] = $like; $params[] = $like;
        }
        if ($status !== '' && in_array($status, $ticketStatuses, true)) {
          $where[] = 't.status = ?';
          $params[] = $status;
        }
        if ($type !== '' && in_array($type, $ticketTypes, true)) {
          $where[] = 't.type = ?';
          $params[] = $type;
        }
        if ($priority !== '' && in_array($priority, $ticketPriorities, true)) {
          $where[] = 't.priority = ?';
          $params[] = $priority;
        }

        $whereSql = $where ? ('WHERE '.implode(' AND ', $where)) : '';

        $countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets t $whereSql");
        $countStmt->execute($params);
        $total = (int)($countStmt->fetchColumn() ?: 0);

        $sql = "SELECT t.*, u.email AS created_by_email, u.name AS created_by_name,
                       uu.email AS updated_by_email, uu.name AS updated_by_name
                FROM tickets t
                LEFT JOIN users u ON u.id = t.created_by
                LEFT JOIN users uu ON uu.id = t.updated_by
                $whereSql
                ORDER BY t.updated_at DESC, t.created_at DESC
                LIMIT $limit OFFSET $offset";
        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);
        $items = $stmt->fetchAll() ?: [];

        json([
          'ok' => true,
          'ready' => true,
          'items' => $items,
          'total' => $total,
          'limit' => $limit,
          'offset' => $offset
        ]);
      }

      if ($method === 'GET' && preg_match('#^/tickets/([0-9]+)$#', $uri, $tm)) {
        $id = (int)$tm[1];
        $stmt = $pdo->prepare("SELECT t.*, u.email AS created_by_email, u.name AS created_by_name,
                                       uu.email AS updated_by_email, uu.name AS updated_by_name
                                FROM tickets t
                                LEFT JOIN users u ON u.id = t.created_by
                                LEFT JOIN users uu ON uu.id = t.updated_by
                                WHERE t.id = ? LIMIT 1");
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        if (!$row) { json(['error' => 'not_found'], 404); }
        json(['ok' => true, 'ticket' => $row]);
      }

      if ($method === 'POST' && $uri === '/tickets') {
        $raw = file_get_contents('php://input') ?: '';
        $in = json_decode($raw, true);
        if (!is_array($in)) { $in = $_POST ?: []; }

        $title = trim((string)($in['title'] ?? ''));
        if ($title === '') { json(['error' => 'title_required'], 422); }

        $type = (string)($in['type'] ?? 'bug');
        if (!in_array($type, $ticketTypes, true)) $type = 'bug';
        $priority = (string)($in['priority'] ?? 'medium');
        if (!in_array($priority, $ticketPriorities, true)) $priority = 'medium';
        $status = (string)($in['status'] ?? 'open');
        if (!in_array($status, $ticketStatuses, true)) $status = 'open';

        if (($auth['role'] ?? '') !== 'superadmin' && !in_array($status, ['open','closed'], true)) {
          $status = 'open';
        }

        $tagsIn = $in['tags'] ?? '';
        $tags = is_array($tagsIn) ? implode(', ', array_filter(array_map('trim', $tagsIn))) : trim((string)$tagsIn);
        $url = trim((string)($in['url'] ?? ''));
        $description = trim((string)($in['description'] ?? ''));
        $steps = trim((string)($in['steps'] ?? ''));
        $expected = trim((string)($in['expected'] ?? ''));
        $actual = trim((string)($in['actual'] ?? ''));
        $context = trim((string)($in['context'] ?? ''));
        $image = isset($in['image']) ? trim((string)$in['image']) : null;

        $stmt = $pdo->prepare(
          'INSERT INTO tickets (title,type,priority,status,tags,url,description,steps,expected,actual,context,image,created_by,updated_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())'
        );
        $stmt->execute([
          $title, $type, $priority, $status, $tags, $url, $description, $steps, $expected, $actual, $context, $image,
          (int)$auth['id'], (int)$auth['id']
        ]);

        $id = (int)$pdo->lastInsertId();
        $rowStmt = $pdo->prepare("SELECT t.*, u.email AS created_by_email, u.name AS created_by_name,
                                         uu.email AS updated_by_email, uu.name AS updated_by_name
                                  FROM tickets t
                                  LEFT JOIN users u ON u.id = t.created_by
                                  LEFT JOIN users uu ON uu.id = t.updated_by
                                  WHERE t.id = ? LIMIT 1");
        $rowStmt->execute([$id]);
        $row = $rowStmt->fetch();
        json(['ok' => true, 'ticket' => $row], 201);
      }

      if ($method === 'PUT' && preg_match('#^/tickets/([0-9]+)$#', $uri, $tm)) {
        $id = (int)$tm[1];
        $rowStmt = $pdo->prepare('SELECT * FROM tickets WHERE id = ? LIMIT 1');
        $rowStmt->execute([$id]);
        $current = $rowStmt->fetch();
        if (!$current) { json(['error' => 'not_found'], 404); }

        $isSuper = (($auth['role'] ?? '') === 'superadmin');
        if (!$isSuper && (int)$current['created_by'] !== (int)$auth['id']) {
          json(['error' => 'forbidden'], 403);
        }

        $raw = file_get_contents('php://input') ?: '';
        $in = json_decode($raw, true);
        if (!is_array($in)) { $in = $_POST ?: []; }

        $set = [];
        $params = [];

        if (array_key_exists('title', $in)) {
          $title = trim((string)$in['title']);
          if ($title === '') { json(['error' => 'title_required'], 422); }
          $set[] = 'title = ?';
          $params[] = $title;
        }
        if (array_key_exists('type', $in)) {
          $type = (string)$in['type'];
          if (!in_array($type, $ticketTypes, true)) { json(['error' => 'bad_type'], 422); }
          $set[] = 'type = ?';
          $params[] = $type;
        }
        if (array_key_exists('priority', $in)) {
          $priority = (string)$in['priority'];
          if (!in_array($priority, $ticketPriorities, true)) { json(['error' => 'bad_priority'], 422); }
          $set[] = 'priority = ?';
          $params[] = $priority;
        }
        if (array_key_exists('status', $in)) {
          $status = (string)$in['status'];
          if (!in_array($status, $ticketStatuses, true)) { json(['error' => 'bad_status'], 422); }
          if (!$isSuper && $status !== $current['status'] && $status !== 'closed') {
            json(['error' => 'forbidden_status_change'], 403);
          }
          $set[] = 'status = ?';
          $params[] = $status;
        }
        if (array_key_exists('tags', $in)) {
          $tagsIn = $in['tags'];
          $tags = is_array($tagsIn) ? implode(', ', array_filter(array_map('trim', $tagsIn))) : trim((string)$tagsIn);
          $set[] = 'tags = ?';
          $params[] = $tags;
        }
        if (array_key_exists('url', $in)) {
          $set[] = 'url = ?';
          $params[] = trim((string)$in['url']);
        }
        if (array_key_exists('description', $in)) {
          $set[] = 'description = ?';
          $params[] = trim((string)$in['description']);
        }
        if (array_key_exists('steps', $in)) {
          $set[] = 'steps = ?';
          $params[] = trim((string)$in['steps']);
        }
        if (array_key_exists('expected', $in)) {
          $set[] = 'expected = ?';
          $params[] = trim((string)$in['expected']);
        }
        if (array_key_exists('actual', $in)) {
          $set[] = 'actual = ?';
          $params[] = trim((string)$in['actual']);
        }
        if (array_key_exists('context', $in)) {
          $set[] = 'context = ?';
          $params[] = trim((string)$in['context']);
        }
        if (array_key_exists('image', $in)) {
          $set[] = 'image = ?';
          $params[] = $in['image'] !== null ? trim((string)$in['image']) : null;
        }

        $set[] = 'updated_by = ?';
        $params[] = (int)$auth['id'];
        $set[] = 'updated_at = NOW()';

        $sql = 'UPDATE tickets SET '.implode(', ', $set).' WHERE id = ?';
        $params[] = $id;
        $upd = $pdo->prepare($sql);
        $upd->execute($params);

        $outStmt = $pdo->prepare("SELECT t.*, u.email AS created_by_email, u.name AS created_by_name,
                                         uu.email AS updated_by_email, uu.name AS updated_by_name
                                  FROM tickets t
                                  LEFT JOIN users u ON u.id = t.created_by
                                  LEFT JOIN users uu ON uu.id = t.updated_by
                                  WHERE t.id = ? LIMIT 1");
        $outStmt->execute([$id]);
        $row = $outStmt->fetch();
        json(['ok' => true, 'ticket' => $row]);
      }

      if ($method === 'DELETE' && preg_match('#^/tickets/([0-9]+)$#', $uri, $tm)) {
        if (($auth['role'] ?? '') !== 'superadmin') { json(['error' => 'forbidden'], 403); }
        $id = (int)$tm[1];
        $stmt = $pdo->prepare('DELETE FROM tickets WHERE id = ?');
        $stmt->execute([$id]);
        json(['ok' => true]);
      }

      // Get comments for a ticket
      if ($method === 'GET' && preg_match('#^/tickets/([0-9]+)/comments$#', $uri, $tm)) {
        $ticketId = (int)$tm[1];
        
        // Check if table exists
        $tableCheck = $pdo->query("SHOW TABLES LIKE 'ticket_comments'")->fetch();
        if (!$tableCheck) {
          json(['ok' => true, 'comments' => [], 'ready' => false]);
        }
        
        // Verify ticket exists
        $ticketStmt = $pdo->prepare('SELECT id FROM tickets WHERE id = ? LIMIT 1');
        $ticketStmt->execute([$ticketId]);
        if (!$ticketStmt->fetch()) {
          json(['error' => 'ticket_not_found'], 404);
        }
        
        $stmt = $pdo->prepare(
          'SELECT tc.*, u.name AS user_name, u.email AS user_email 
           FROM ticket_comments tc
           LEFT JOIN users u ON u.id = tc.user_id
           WHERE tc.ticket_id = ?
           ORDER BY tc.created_at ASC'
        );
        $stmt->execute([$ticketId]);
        $comments = $stmt->fetchAll() ?: [];
        
        json(['ok' => true, 'comments' => $comments, 'ready' => true]);
      }

      // Post a comment to a ticket
      if ($method === 'POST' && preg_match('#^/tickets/([0-9]+)/comments$#', $uri, $tm)) {
        $ticketId = (int)$tm[1];
        
        // Verify ticket exists
        $ticketStmt = $pdo->prepare('SELECT id FROM tickets WHERE id = ? LIMIT 1');
        $ticketStmt->execute([$ticketId]);
        if (!$ticketStmt->fetch()) {
          json(['error' => 'ticket_not_found'], 404);
        }
        
        $raw = file_get_contents('php://input') ?: '';
        $in = json_decode($raw, true);
        if (!is_array($in)) { $in = $_POST ?: []; }
        
        $message = trim((string)($in['message'] ?? ''));
        if ($message === '') {
          json(['error' => 'message_required'], 422);
        }
        
        $stmt = $pdo->prepare(
          'INSERT INTO ticket_comments (ticket_id, user_id, message, created_at) VALUES (?, ?, ?, NOW())'
        );
        $stmt->execute([$ticketId, (int)$auth['id'], $message]);
        
        $commentId = (int)$pdo->lastInsertId();
        $commentStmt = $pdo->prepare(
          'SELECT tc.*, u.name AS user_name, u.email AS user_email 
           FROM ticket_comments tc
           LEFT JOIN users u ON u.id = tc.user_id
           WHERE tc.id = ? LIMIT 1'
        );
        $commentStmt->execute([$commentId]);
        $comment = $commentStmt->fetch();
        
        // Create notifications for involved users (wrap entire section in try-catch)
        file_put_contents('/tmp/notification_debug.log', date('Y-m-d H:i:s') . " [DEBUG] Starting notification creation for ticket $ticketId" . PHP_EOL, FILE_APPEND);
        try {
          // 1. Get the ticket creator
          $ticketCreatorStmt = $pdo->prepare('SELECT created_by, title FROM tickets WHERE id = ? LIMIT 1');
          $ticketCreatorStmt->execute([$ticketId]);
          $ticketData = $ticketCreatorStmt->fetch();
          
          file_put_contents('/tmp/notification_debug.log', date('Y-m-d H:i:s') . " [DEBUG] ticketData: " . ($ticketData ? "created_by=" . $ticketData['created_by'] . ", title=" . $ticketData['title'] : "NULL") . PHP_EOL, FILE_APPEND);
          if ($ticketData) {
            // 2. Get all unique users who have commented (except current user)
            $commentersStmt = $pdo->prepare(
              'SELECT DISTINCT user_id FROM ticket_comments WHERE ticket_id = ? AND user_id != ?'
            );
            $commentersStmt->execute([$ticketId, (int)$auth['id']]);
            $commenters = $commentersStmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
            
            // 3. Add ticket creator to notification list
            $notifyUsers = [];
            if ($ticketData['created_by'] != (int)$auth['id']) {
              $notifyUsers[] = $ticketData['created_by'];
            }
            
            // 4. Add all other commenters (excluding duplicates)
            foreach ($commenters as $userId) {
              if (!in_array($userId, $notifyUsers) && $userId != (int)$auth['id']) {
                $notifyUsers[] = $userId;
              }
            }
            
            // 5. Create notifications
            if (!empty($notifyUsers)) {
              file_put_contents('/tmp/notification_debug.log', date('Y-m-d H:i:s') . " [DEBUG] Creating " . count($notifyUsers) . " notifications for users:" . implode(',', $notifyUsers) . PHP_EOL, FILE_APPEND);
              $notifMsg = "{$auth['name']} commented on ticket: " . htmlspecialchars($ticketData['title']);
              $notifStmt = $pdo->prepare(
                'INSERT INTO notifications (user_id, type, ticket_id, message, created_by) 
                 VALUES (?, ?, ?, ?, ?)'
              );
              
              foreach ($notifyUsers as $userId) {
                $notifStmt->execute([$userId, 'ticket_comment', $ticketId, $notifMsg, (int)$auth['id']]);
              }
              file_put_contents('/tmp/notification_debug.log', date('Y-m-d H:i:s') . " [DEBUG] Notifications created successfully" . PHP_EOL, FILE_APPEND);
            }
          }
        } catch (PDOException $e) {
          // Log the error for debugging - write to simple file
          $logMsg = date('Y-m-d H:i:s') . " [Notification Error] " . $e->getMessage() . " [Code: " . $e->getCode() . "]" . PHP_EOL;
          file_put_contents('/tmp/notification_errors.log', $logMsg, FILE_APPEND);
          error_log("Notification creation error: " . $e->getMessage() . " [Code: " . $e->getCode() . "]");
          // Continue execution and return the comment successfully
        }
        
        json(['ok' => true, 'comment' => $comment], 201);
      }
    } catch (Throwable $e) {
      if ($e instanceof PDOException) {
        $info = $e->errorInfo ?? [];
        $code = $info[1] ?? null;
        if (($e->getCode() === '42S02') || ($code === 1146)) {
          json(['error' => 'tickets_not_ready'], 503);
        }
      }
      json(['error' => 'server_error'], 500);
    }
  }
  // ==== END TICKETS API ====

  // ==== NOTIFICATIONS API ====
  if (str_starts_with($uri, '/notifications')) {
    // Load session but don't fail if not authenticated - allow unauthenticated requests to return empty
    ct_start_auth_session();
    $auth = $_SESSION['auth'] ?? null;
    $pdo = db();
    try {
      // If not authenticated, return empty notifications instead of 401
      if (!$auth) {
        if ($method === 'GET' && $uri === '/notifications') {
          json(['ok' => true, 'notifications' => [], 'unread_count' => 0, 'ready' => true]);
        }
        // For mark-as-read endpoints, require auth
        json(['error' => 'auth_required'], 401);
      }
      
      // GET /notifications - Get user's notifications (unread first, then recent)
      if ($method === 'GET' && $uri === '/notifications') {
        $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
        if ($limit < 1) $limit = 1;
        if ($limit > 100) $limit = 100;
        
        // Check if table exists
        $tableCheck = $pdo->query("SHOW TABLES LIKE 'notifications'")->fetch();
        if (!$tableCheck) {
          json(['ok' => true, 'notifications' => [], 'unread_count' => 0, 'ready' => false]);
        }
        
        $stmt = $pdo->prepare(
          'SELECT n.*, 
                  u.name AS created_by_name,
                  t.title AS ticket_title
           FROM notifications n
           LEFT JOIN users u ON u.id = n.created_by
           LEFT JOIN tickets t ON t.id = n.ticket_id
           WHERE n.user_id = ?
           ORDER BY n.read_at IS NULL DESC, n.created_at DESC
           LIMIT ' . (int)$limit
        );
        $stmt->execute([(int)$auth['id']]);
        $notifications = $stmt->fetchAll() ?: [];
        
        // Get unread count
        $countStmt = $pdo->prepare('SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL');
        $countStmt->execute([(int)$auth['id']]);
        $unreadCount = (int)$countStmt->fetchColumn();
        
        json(['ok' => true, 'notifications' => $notifications, 'unread_count' => $unreadCount, 'ready' => true]);
      }
      
      // POST /notifications/{id}/read - Mark single notification as read
      if ($method === 'POST' && preg_match('#^/notifications/([0-9]+)/read$#', $uri, $nm)) {
        if (!$auth) { json(['error' => 'auth_required'], 401); }
        $notifId = (int)$nm[1];
        
        // Check if table exists
        $tableCheck = $pdo->query("SHOW TABLES LIKE 'notifications'")->fetch();
        if (!$tableCheck) {
          json(['error' => 'notifications_not_ready'], 503);
        }
        
        // Verify notification belongs to user
        $checkStmt = $pdo->prepare('SELECT id FROM notifications WHERE id = ? AND user_id = ? LIMIT 1');
        $checkStmt->execute([$notifId, (int)$auth['id']]);
        if (!$checkStmt->fetch()) {
          json(['error' => 'notification_not_found'], 404);
        }
        
        $stmt = $pdo->prepare('UPDATE notifications SET read_at = NOW() WHERE id = ? AND read_at IS NULL');
        $stmt->execute([$notifId]);
        
        json(['ok' => true]);
      }
      
      // POST /notifications/read-all - Mark all user's notifications as read
      if ($method === 'POST' && $uri === '/notifications/read-all') {
        if (!$auth) { json(['error' => 'auth_required'], 401); }
        
        // Check if table exists
        $tableCheck = $pdo->query("SHOW TABLES LIKE 'notifications'")->fetch();
        if (!$tableCheck) {
          json(['ok' => true, 'marked_read' => 0, 'ready' => false]);
        }
        
        $stmt = $pdo->prepare('UPDATE notifications SET read_at = NOW() WHERE user_id = ? AND read_at IS NULL');
        $stmt->execute([(int)$auth['id']]);
        $count = $stmt->rowCount();
        
        json(['ok' => true, 'marked_read' => $count]);
      }
      
      // DELETE /notifications/{id} - Delete single notification
      if ($method === 'DELETE' && preg_match('#^/notifications/([0-9]+)$#', $uri, $nm)) {
        if (!$auth) { json(['error' => 'auth_required'], 401); }
        $notifId = (int)$nm[1];
        
        // Check if table exists
        $tableCheck = $pdo->query("SHOW TABLES LIKE 'notifications'")->fetch();
        if (!$tableCheck) {
          json(['error' => 'notifications_not_ready'], 503);
        }
        
        // Verify notification belongs to user and delete in one query
        $stmt = $pdo->prepare('DELETE FROM notifications WHERE id = ? AND user_id = ?');
        $stmt->execute([$notifId, (int)$auth['id']]);
        
        if ($stmt->rowCount() === 0) {
          json(['error' => 'notification_not_found'], 404);
        }
        
        json(['ok' => true]);
      }
      
    } catch (Throwable $e) {
      error_log("NOTIFICATIONS ERROR: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
      if ($e instanceof PDOException) {
        $info = $e->errorInfo ?? [];
        $code = $info[1] ?? null;
        if (($e->getCode() === '42S02') || ($code === 1146)) {
          json(['error' => 'notifications_not_ready'], 503);
        }
      }
      json(['error' => 'server_error'], 500);
    }
  }
  // ==== END NOTIFICATIONS API ====

  // ==== BACKUP API (SuperAdmin Only) ====
  if (str_starts_with($uri, '/admin/backups')) {
    $auth = require_session_auth(['superadmin']);
    
    // GET /admin/backups - List all backups
    if ($method === 'GET' && $uri === '/admin/backups') {
      $appRoot = dirname(__DIR__);
      $backupRoot = ct_resolve_backup_root($appRoot);
      
      if (!is_dir($backupRoot)) {
        json(['ok' => true, 'backups' => [], 'backup_root' => $backupRoot]);
      }
      
      $backupDirs = glob($backupRoot . '/20*', GLOB_ONLYDIR);
      rsort($backupDirs); // Most recent first
      
      $backups = [];
      foreach ($backupDirs as $dir) {
        $backupId = basename($dir);
        $reportFile = $dir . '/backup-report.json';
        $verifyFile = $dir . '/verification-report.json';
        
        $backup = [
          'id' => $backupId,
          'timestamp' => date('Y-m-d H:i:s', filemtime($dir)),
          'age_hours' => round((time() - filemtime($dir)) / 3600, 1),
          'size' => 'unknown'
        ];
        
        if (file_exists($reportFile)) {
          $report = json_decode(file_get_contents($reportFile), true);
          $backup = array_merge($backup, $report);
        }
        
        if (file_exists($verifyFile)) {
          $verify = json_decode(file_get_contents($verifyFile), true);
          $backup['verification'] = $verify;
        }
        
        $backups[] = $backup;
      }
      
      json(['ok' => true, 'backups' => $backups, 'count' => count($backups)]);
    }
    
    // POST /admin/backups/create - Trigger manual backup
    if ($method === 'POST' && $uri === '/admin/backups/create') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      $type = $input['type'] ?? 'full'; // full|database|files
      
      if (!in_array($type, ['full', 'database', 'files'])) {
        json(['error' => 'invalid_backup_type'], 400);
      }
      
      $appRoot = dirname(__DIR__);
      $backupScript = $appRoot . '/deploy/backup.sh';
      $backupRoot = ct_resolve_backup_root($appRoot);
      
      if (!file_exists($backupScript)) {
        json(['error' => 'backup_script_not_found'], 500);
      }

      if (!is_dir($backupRoot) && !@mkdir($backupRoot, 0775, true) && !is_dir($backupRoot)) {
        json(['error' => 'backup_root_create_failed', 'backup_root' => $backupRoot], 500);
      }
      if (!is_writable($backupRoot)) {
        json(['error' => 'backup_root_not_writable', 'backup_root' => $backupRoot], 500);
      }
      
      // Run backup in background and persist stdout/stderr to a job log for diagnostics.
      $jobLog = $backupRoot . '/manual-backup-' . date('Ymd-His') . '.log';
      $cmd = sprintf(
        'cd %s && nohup bash deploy/backup.sh --type=%s > %s 2>&1 & echo $!',
        escapeshellarg($appRoot),
        escapeshellarg($type),
        escapeshellarg($jobLog)
      );
      
      $launchOutput = [];
      $launchCode = 0;
      exec($cmd, $launchOutput, $launchCode);
      $pid = trim((string)($launchOutput[0] ?? ''));

      if ($launchCode !== 0 || $pid === '' || !ctype_digit($pid)) {
        json([
          'error' => 'backup_start_failed',
          'backup_root' => $backupRoot,
          'job_log' => basename($jobLog),
        ], 500);
      }
      
      json([
        'ok' => true,
        'message' => 'Backup started in background',
        'type' => $type,
        'pid' => (int)$pid,
        'job_log' => basename($jobLog),
        'backup_root' => $backupRoot,
      ]);
    }
    
    // POST /admin/backups/verify - Verify specific backup
    if ($method === 'POST' && $uri === '/admin/backups/verify') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      $backupId = $input['backup_id'] ?? null;
      
      if (!$backupId) {
        json(['error' => 'backup_id_required'], 400);
      }
      
      $appRoot = dirname(__DIR__);
      $verifyScript = $appRoot . '/deploy/verify-backup.php';
      
      if (!file_exists($verifyScript)) {
        json(['error' => 'verify_script_not_found'], 500);
      }
      
      // Run verification
      $cmd = sprintf(
        'cd %s && php deploy/verify-backup.php --backup-id=%s 2>&1',
        escapeshellarg($appRoot),
        escapeshellarg($backupId)
      );
      
      exec($cmd, $output, $returnCode);
      
      json([
        'ok' => $returnCode === 0,
        'backup_id' => $backupId,
        'output' => implode("\n", $output),
        'status' => $returnCode === 0 ? 'PASSED' : 'FAILED'
      ]);
    }
    
    // POST /admin/backups/test-restore - Test restore (creates temporary test database)
    if ($method === 'POST' && $uri === '/admin/backups/test-restore') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      $backupId = $input['backup_id'] ?? null;
      
      if (!$backupId) {
        json(['error' => 'backup_id_required'], 400);
      }
      
      $appRoot = dirname(__DIR__);
      $verifyScript = $appRoot . '/deploy/verify-backup.php';
      
      if (!file_exists($verifyScript)) {
        json(['error' => 'verify_script_not_found'], 500);
      }
      
      // Run restore test
      $initiatedBy = 'ui-superadmin-' . ((string)($auth['id'] ?? 'unknown'));
      $cmd = sprintf(
        'cd %s && php deploy/verify-backup.php --backup-id=%s --test-restore --persist-drill --drill-mode=manual --initiated-by=%s 2>&1',
        escapeshellarg($appRoot),
        escapeshellarg($backupId),
        escapeshellarg($initiatedBy)
      );
      
      exec($cmd, $output, $returnCode);
      
      json([
        'ok' => $returnCode === 0,
        'backup_id' => $backupId,
        'output' => implode("\n", $output),
        'status' => $returnCode === 0 ? 'PASSED' : 'FAILED'
      ]);
    }

    // GET /admin/backups/drills - List restore drill runs (M-03 evidence)
    if ($method === 'GET' && $uri === '/admin/backups/drills') {
      $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
      if ($limit < 1) $limit = 1;
      if ($limit > 200) $limit = 200;

      $pdo = db();
      $tableCheck = $pdo->query("SHOW TABLES LIKE 'restore_drill_runs'")->fetch();
      if (!$tableCheck) {
        json(['ok' => true, 'runs' => [], 'ready' => false]);
      }

      $stmt = $pdo->prepare(
        "SELECT id, backup_id, drill_mode, initiated_by, started_at, completed_at, status, rto_seconds, rpo_minutes, table_count, error_message, created_at
         FROM restore_drill_runs
         ORDER BY started_at DESC
         LIMIT ?"
      );
      $stmt->bindValue(1, $limit, PDO::PARAM_INT);
      $stmt->execute();
      $runs = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];

      json(['ok' => true, 'runs' => $runs, 'ready' => true]);
    }

    // POST /admin/backups/drills/run - Run monthly-style restore drill now
    if ($method === 'POST' && $uri === '/admin/backups/drills/run') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      $backupId = $input['backup_id'] ?? null;

      $appRoot = dirname(__DIR__);
      $runnerScript = $appRoot . '/deploy/run-monthly-restore-drill.sh';
      if (!file_exists($runnerScript)) {
        json(['error' => 'monthly_drill_runner_not_found'], 500);
      }

      if ($backupId) {
        $cmd = sprintf(
          'cd %s && bash deploy/run-monthly-restore-drill.sh --backup-id=%s 2>&1',
          escapeshellarg($appRoot),
          escapeshellarg((string)$backupId)
        );
      } else {
        $cmd = sprintf(
          'cd %s && bash deploy/run-monthly-restore-drill.sh 2>&1',
          escapeshellarg($appRoot)
        );
      }

      exec($cmd, $output, $returnCode);

      json([
        'ok' => $returnCode === 0,
        'output' => implode("\n", $output),
        'status' => $returnCode === 0 ? 'PASSED' : 'FAILED'
      ]);
    }
    
    json(['error' => 'not_found'], 404);
  }
  // ==== END BACKUP API ====

  // GET /admin/publication/settings - Publication snippet preset settings for authenticated users
  if ($uri === '/admin/publication/settings' && $method === 'GET') {
    $auth = require_session_auth();
    $pdo = db();
    $payload = ct_publication_settings_payload($pdo);
    json([
      'ok' => true,
      'publication' => [
        'default_preset' => $payload['default_preset'],
        'presets' => $payload['presets'],
        'table_ready' => $payload['table_ready'],
      ],
      'can_manage' => (($auth['role'] ?? '') === 'superadmin'),
    ]);
  }

  // ==== SETTINGS API (SuperAdmin Only) ====
  if (str_starts_with($uri, '/admin/settings')) {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    // GET /admin/settings - Get all system settings (organized by namespace)
    if ($method === 'GET' && $uri === '/admin/settings') {
      // Check if table exists
      $tableCheck = $pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if (!$tableCheck) {
        json(['error' => 'settings_table_not_ready', 'message' => 'Run migration: 2026-03-04-add-system-settings-table.sql'], 503);
      }
      
      $stmt = $pdo->query("SELECT setting_key, setting_value, setting_type, description FROM system_settings ORDER BY setting_key");
      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      // Organize by namespace (e.g., backup.notify_email → backup: {notify_email: ...})
      $settings = [];
      foreach ($rows as $row) {
        $key = $row['setting_key'];
        $value = $row['setting_value'];
        $type = $row['setting_type'];
        
        // Type conversion
        if ($type === 'boolean') {
          $value = in_array(strtolower($value), ['true', '1', 'yes'], true);
        } elseif ($type === 'integer') {
          $value = (int)$value;
        } elseif ($type === 'json') {
          $value = json_decode($value, true) ?: $value;
        }
        
        // Split by namespace
        if (strpos($key, '.') !== false) {
          list($namespace, $subkey) = explode('.', $key, 2);
          if (!isset($settings[$namespace])) {
            $settings[$namespace] = [];
          }
          $settings[$namespace][$subkey] = $value;
        } else {
          $settings[$key] = $value;
        }
      }
      
      json(['ok' => true, 'settings' => $settings]);
    }
    
    // GET /admin/settings/backup - Get backup settings only
    if ($method === 'GET' && $uri === '/admin/settings/backup') {
      $tableCheck = $pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if (!$tableCheck) {
        // Return defaults if table doesn't exist yet
        json([
          'ok' => true,
          'settings' => [
            'notify_email' => '',
            'retention_days' => 30,
            'encryption_enabled' => true,
            'offsite_enabled' => false,
            's3_bucket' => '',
            'schedule_time' => '02:30'
          ],
          'table_ready' => false
        ]);
      }
      
      $stmt = $pdo->query("SELECT setting_key, setting_value, setting_type FROM system_settings WHERE setting_key LIKE 'backup.%'");
      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      $backupSettings = [];
      foreach ($rows as $row) {
        $key = str_replace('backup.', '', $row['setting_key']);
        $value = $row['setting_value'];
        $type = $row['setting_type'];
        
        // Type conversion
        if ($type === 'boolean') {
          $value = in_array(strtolower($value), ['true', '1', 'yes'], true);
        } elseif ($type === 'integer') {
          $value = (int)$value;
        }
        
        $backupSettings[$key] = $value;
      }
      
      json(['ok' => true, 'settings' => $backupSettings, 'table_ready' => true]);
    }
    
    // PUT /admin/settings/backup - Update backup settings
    if ($method === 'PUT' && $uri === '/admin/settings/backup') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      
      $tableCheck = $pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if (!$tableCheck) {
        json(['error' => 'settings_table_not_ready', 'message' => 'Run migration first'], 503);
      }
      
      $allowedSettings = [
        'notify_email' => 'string',
        'retention_days' => 'integer',
        'encryption_enabled' => 'boolean',
        'offsite_enabled' => 'boolean',
        's3_bucket' => 'string',
        'schedule_time' => 'string'
      ];
      
      $updated = [];
      foreach ($allowedSettings as $key => $type) {
        if (!isset($input[$key])) continue;
        
        $value = $input[$key];
        $fullKey = 'backup.' . $key;
        
        // Type validation and conversion
        if ($type === 'boolean') {
          $value = $value ? 'true' : 'false';
        } elseif ($type === 'integer') {
          $value = (string)(int)$value;
          if ((int)$value < 1) {
            json(['error' => 'invalid_value', 'field' => $key, 'message' => 'Value must be positive'], 400);
          }
        } elseif ($type === 'string') {
          $value = (string)$value;
        }
        
        // Upsert setting
        $stmt = $pdo->prepare("
          INSERT INTO system_settings (setting_key, setting_value, setting_type)
          VALUES (?, ?, ?)
          ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
        ");
        $stmt->execute([$fullKey, $value, $type]);
        $updated[$key] = $value;
      }
      
      if (empty($updated)) {
        json(['error' => 'no_valid_settings', 'message' => 'No valid settings provided'], 400);
      }
      
      json(['ok' => true, 'message' => 'Backup settings updated', 'updated' => array_keys($updated)]);
    }

    // GET /admin/settings/publication - Publication (ESP preset) settings
    if ($method === 'GET' && $uri === '/admin/settings/publication') {
      $payload = ct_publication_settings_payload($pdo);
      json([
        'ok' => true,
        'publication' => [
          'default_preset' => $payload['default_preset'],
          'presets' => $payload['all_presets'],
          'table_ready' => $payload['table_ready'],
        ],
      ]);
    }

    // PUT /admin/settings/publication - Update publication (ESP preset) settings
    if ($method === 'PUT' && $uri === '/admin/settings/publication') {
      if (!system_settings_table_ready($pdo)) {
        json(['ok' => false, 'error' => 'settings_table_not_ready', 'message' => 'Run migration first'], 503);
      }

      $input = json_decode(file_get_contents('php://input'), true) ?: [];
      $presets = ct_normalize_publication_presets($input['presets'] ?? null);
      $requestedDefault = strtolower(trim((string)($input['default_preset'] ?? '')));
      $enabledKeys = array_column(array_values(array_filter($presets, static fn(array $preset): bool => !empty($preset['enabled']))), 'key');
      if (!$enabledKeys) {
        $enabledKeys = array_column($presets, 'key');
      }
      $defaultPreset = in_array($requestedDefault, $enabledKeys, true)
        ? $requestedDefault
        : ((string)($enabledKeys[0] ?? 'generic'));

      system_settings_upsert($pdo, 'publication.esp_presets', $presets, 'json');
      system_settings_upsert($pdo, 'publication.default_preset', $defaultPreset, 'string');

      json([
        'ok' => true,
        'publication' => [
          'default_preset' => $defaultPreset,
          'presets' => $presets,
        ],
      ]);
    }

    // GET /admin/settings/health/migrations - Migration ledger + pending files summary
    if ($method === 'GET' && $uri === '/admin/settings/health/migrations') {
      $appRoot = dirname(__DIR__);
      $sqlDir = $appRoot . '/sql';

      $host = strtolower((string)($_SERVER['HTTP_HOST'] ?? ''));
      $appEnv = strtolower((string)(getenv('APP_ENV') ?: ($_ENV['APP_ENV'] ?? '')));
      $hostEnvironment = 'staging';
      if (str_contains($host, 'trk.jsjdmedia.com') && !str_contains($host, 'trk-staging')) {
        $hostEnvironment = 'production';
      } elseif ($appEnv === 'production') {
        $hostEnvironment = 'production';
      }

      $ledgerReady = (bool)$pdo->query("SHOW TABLES LIKE 'schema_migrations'")->fetch();
      $appliedMap = [];
      $appliedCount = 0;
      $latestApplied = null;
      $latestAppliedAt = null;
      $lastRun = system_settings_get($pdo, 'migrations.last_run', []);
      if (!is_array($lastRun)) {
        $lastRun = [];
      }

      if ($ledgerReady) {
        $stmt = $pdo->query("SELECT migration, applied_at FROM schema_migrations ORDER BY applied_at ASC");
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
          $name = (string)($row['migration'] ?? '');
          if ($name !== '') {
            $appliedMap[$name] = true;
            $appliedCount++;
            $latestApplied = $name;
            $latestAppliedAt = (string)($row['applied_at'] ?? '');
          }
        }
      }

      $migrationFiles = glob($sqlDir . '/*.sql') ?: [];
      sort($migrationFiles);

      $excluded = [
        '2026-02-27-create-db-roles.sql',
        'verify-db-privileges.sql',
      ];
      $excludedSet = array_flip($excluded);

      $pending = [];
      foreach ($migrationFiles as $file) {
        $filename = basename($file);
        if (isset($excludedSet[$filename])) continue;
        if (!isset($appliedMap[$filename])) {
          $pending[] = $filename;
        }
      }

      json([
        'ok' => true,
        'migration' => [
          'ledger_ready' => $ledgerReady,
          'applied_count' => $appliedCount,
          'pending_count' => count($pending),
          'pending_migrations' => $pending,
          'latest_applied' => $latestApplied,
          'latest_applied_at' => $latestAppliedAt,
          'host_environment' => $hostEnvironment,
          'can_run_staging' => $hostEnvironment === 'staging',
          'can_run_production' => $hostEnvironment === 'production',
          'last_run' => $lastRun,
        ],
      ]);
    }

    // POST /admin/settings/health/migrations/run - Execute pending migrations for current host environment
    if ($method === 'POST' && $uri === '/admin/settings/health/migrations/run') {
      $appRoot = dirname(__DIR__);
      $runner = $appRoot . '/deploy/run-migration.sh';
      if (!is_file($runner)) {
        json(['ok' => false, 'error' => 'migration_runner_not_found'], 500);
      }

      $raw = file_get_contents('php://input') ?: '{}';
      $body = json_decode($raw, true);
      if (!is_array($body)) {
        $body = [];
      }

      $requestedTarget = strtolower(trim((string)($body['target'] ?? 'staging')));
      if (!in_array($requestedTarget, ['staging', 'production'], true)) {
        json(['ok' => false, 'error' => 'bad_target'], 422);
      }

      $host = strtolower((string)($_SERVER['HTTP_HOST'] ?? ''));
      $appEnv = strtolower((string)(getenv('APP_ENV') ?: ($_ENV['APP_ENV'] ?? '')));
      $hostEnvironment = 'staging';
      if (str_contains($host, 'trk.jsjdmedia.com') && !str_contains($host, 'trk-staging')) {
        $hostEnvironment = 'production';
      } elseif ($appEnv === 'production') {
        $hostEnvironment = 'production';
      }

      if ($requestedTarget !== $hostEnvironment) {
        json([
          'ok' => false,
          'error' => 'cross_environment_not_supported',
          'message' => 'This host can only run migrations for its own environment.',
          'host_environment' => $hostEnvironment,
          'requested_target' => $requestedTarget,
        ], 422);
      }

      $dryRun = !empty($body['dry_run']);
      $args = ['--environment=' . $requestedTarget];
      if ($requestedTarget === 'production') {
        $args[] = '--force';
      }
      if ($dryRun) {
        $args[] = '--dry-run';
      }

      $cmd = sprintf(
        'cd %s && bash deploy/run-migration.sh %s 2>&1',
        escapeshellarg($appRoot),
        implode(' ', array_map('escapeshellarg', $args))
      );
      exec($cmd, $output, $exitCode);

      $status = $exitCode === 0 ? 'passed' : 'failed';
      $outputText = implode("\n", $output);
      $outputTail = array_slice($output, -40);
      $hint = null;
      if ($exitCode !== 0) {
        $lastNonEmpty = '';
        for ($i = count($output) - 1; $i >= 0; $i--) {
          $line = trim((string)$output[$i]);
          if ($line !== '') {
            $lastNonEmpty = $line;
            break;
          }
        }

        if (str_contains($outputText, 'CT_MIGRATOR_PASSWORD not set')) {
          $hint = 'Migration credentials are missing for the web runtime. Ensure CT_MIGRATOR_* is available to the PHP/web user.';
        } elseif (str_contains($outputText, 'Permission denied')) {
          $hint = 'Permission denied while running migration helper. Verify script permissions and web-user access.';
        }

        if ($lastNonEmpty === '') {
          $lastNonEmpty = 'Migration runner exited with non-zero status';
        }
      }

      $state = [
        'ran_at' => date('c'),
        'target' => $requestedTarget,
        'host_environment' => $hostEnvironment,
        'status' => $status,
        'exit_code' => $exitCode,
        'dry_run' => $dryRun,
        'output_excerpt' => array_slice($output, -200),
        'hint' => $hint,
      ];
      system_settings_upsert($pdo, 'migrations.last_run', $state, 'json');

      json([
        'ok' => $exitCode === 0,
        'status' => strtoupper($status),
        'exit_code' => $exitCode,
        'error' => $exitCode === 0 ? null : 'migration_runner_failed',
        'message' => $exitCode === 0 ? 'Migration run completed.' : ($lastNonEmpty ?? 'Migration run failed.'),
        'hint' => $hint,
        'last_run' => $state,
        'output' => $outputText,
        'output_tail' => $outputTail,
      ], $exitCode === 0 ? 200 : 500);
    }

    // GET /admin/settings/health/deployment - Deployment verification status
    if ($method === 'GET' && $uri === '/admin/settings/health/deployment') {
      $appRoot = dirname(__DIR__);
      $stateFile = $appRoot . '/storage/logs/deploy-verification-status.json';

      $scriptMap = [
        'verify_post_deploy' => $appRoot . '/deploy/verify-post-deploy.sh',
        'verify_csp' => $appRoot . '/deploy/verify-csp.sh',
        'verify_nginx_symlinks' => $appRoot . '/deploy/verify-nginx-symlinks.sh',
      ];
      $scripts = [];
      foreach ($scriptMap as $key => $path) {
        $scripts[$key] = file_exists($path);
      }

      $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
      $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
      $baseUrl = rtrim((getenv('APP_URL') ?: ($scheme . '://' . $host)), '/');

      $latestRun = null;
      if (is_file($stateFile)) {
        $parsed = json_decode((string)file_get_contents($stateFile), true);
        if (is_array($parsed)) {
          $latestRun = $parsed;
        }
      }

      $settingsTableReady = (bool)$pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if ($settingsTableReady) {
        $stateStmt = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'deployment.last_verification' LIMIT 1");
        $stateStmt->execute();
        $row = $stateStmt->fetch(PDO::FETCH_ASSOC);
        if ($row && !empty($row['setting_value'])) {
          $dbRun = json_decode((string)$row['setting_value'], true);
          if (is_array($dbRun)) {
            $latestRun = $dbRun;
          }
        }
      }

      json([
        'ok' => true,
        'deployment' => [
          'base_url' => $baseUrl,
          'scripts' => $scripts,
          'latest_run' => $latestRun,
        ],
      ]);
    }

    // POST /admin/settings/health/deployment/verify - Run post-deploy verification
    if ($method === 'POST' && $uri === '/admin/settings/health/deployment/verify') {
      $appRoot = dirname(__DIR__);
      $verifyScript = $appRoot . '/deploy/verify-post-deploy.sh';
      if (!file_exists($verifyScript)) {
        json(['ok' => false, 'error' => 'verify_script_not_found'], 500);
      }

      $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
      $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
      $baseUrl = rtrim((getenv('APP_URL') ?: ($scheme . '://' . $host)), '/');

      $cmd = sprintf(
        'cd %s && bash deploy/verify-post-deploy.sh %s 2>&1',
        escapeshellarg($appRoot),
        escapeshellarg($baseUrl)
      );
      exec($cmd, $output, $returnCode);

      $status = $returnCode === 0 ? 'passed' : 'failed';
      $state = [
        'ran_at' => date('c'),
        'base_url' => $baseUrl,
        'status' => $status,
        'exit_code' => $returnCode,
        'output_excerpt' => array_slice($output, -120),
      ];

      $stateFile = $appRoot . '/storage/logs/deploy-verification-status.json';
      $logDir = dirname($stateFile);
      if (!is_dir($logDir)) {
        @mkdir($logDir, 0775, true);
      }
      @file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

      $settingsTableReady = (bool)$pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if ($settingsTableReady) {
        $setStmt = $pdo->prepare("INSERT INTO system_settings (setting_key, setting_value, setting_type)
          VALUES ('deployment.last_verification', ?, 'json')
          ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()");
        $setStmt->execute([json_encode($state, JSON_UNESCAPED_SLASHES)]);
      }

      json([
        'ok' => $returnCode === 0,
        'status' => strtoupper($status),
        'output' => implode("\n", $output),
        'latest_run' => $state,
      ]);
    }

    // GET /admin/settings/health/bot-integrity - Bot config integrity summary and sample rows
    if ($method === 'GET' && $uri === '/admin/settings/health/bot-integrity') {
      $tableReady = (bool)$pdo->query("SHOW TABLES LIKE 'bot_config_custom'")->fetch();
      if (!$tableReady) {
        json([
          'ok' => true,
          'bot_integrity' => [
            'summary' => [
              'missing_partner_rows' => 0,
              'missing_zone_rows' => 0,
              'mismatched_zone_partner_rows' => 0,
              'total_orphan_rows' => 0,
            ],
            'last_scan_at' => null,
            'orphans' => [],
            'table_ready' => false,
          ],
        ]);
      }

      $summarySt = $pdo->query("SELECT
        SUM(CASE WHEN bc.partner_id IS NOT NULL AND p.id IS NULL THEN 1 ELSE 0 END) AS missing_partner_rows,
        SUM(CASE WHEN bc.zone_id IS NOT NULL AND pz.id IS NULL THEN 1 ELSE 0 END) AS missing_zone_rows,
        SUM(CASE WHEN bc.zone_id IS NOT NULL AND pz.id IS NOT NULL AND bc.partner_id IS NOT NULL AND pz.partner_id <> bc.partner_id THEN 1 ELSE 0 END) AS mismatched_zone_partner_rows
        FROM bot_config_custom bc
        LEFT JOIN partners p ON bc.partner_id = p.id
        LEFT JOIN partner_zones pz ON bc.zone_id = pz.id");
      $summary = $summarySt ? ($summarySt->fetch(PDO::FETCH_ASSOC) ?: []) : [];

      $rowsSt = $pdo->query("SELECT
          bc.id,
          bc.partner_id,
          bc.zone_id,
          bc.config_key,
          p.id AS partner_exists,
          pz.id AS zone_exists,
          pz.partner_id AS zone_partner_id
        FROM bot_config_custom bc
        LEFT JOIN partners p ON bc.partner_id = p.id
        LEFT JOIN partner_zones pz ON bc.zone_id = pz.id
        WHERE (bc.partner_id IS NOT NULL AND p.id IS NULL)
           OR (bc.zone_id IS NOT NULL AND pz.id IS NULL)
           OR (bc.zone_id IS NOT NULL AND pz.id IS NOT NULL AND bc.partner_id IS NOT NULL AND pz.partner_id <> bc.partner_id)
        ORDER BY bc.id DESC
        LIMIT 120");

      $orphans = [];
      if ($rowsSt) {
        while ($row = $rowsSt->fetch(PDO::FETCH_ASSOC)) {
          $reason = [];
          if (!empty($row['partner_id']) && empty($row['partner_exists'])) {
            $reason[] = 'missing_partner';
          }
          if (!empty($row['zone_id']) && empty($row['zone_exists'])) {
            $reason[] = 'missing_zone';
          }
          if (!empty($row['zone_id']) && !empty($row['zone_exists']) && !empty($row['partner_id']) && (int)$row['partner_id'] !== (int)($row['zone_partner_id'] ?? 0)) {
            $reason[] = 'zone_partner_mismatch';
          }
          $row['reason'] = implode(',', $reason);
          $orphans[] = $row;
        }
      }

      $missingPartnerRows = (int)($summary['missing_partner_rows'] ?? 0);
      $missingZoneRows = (int)($summary['missing_zone_rows'] ?? 0);
      $mismatchedRows = (int)($summary['mismatched_zone_partner_rows'] ?? 0);

      $appRoot = dirname(__DIR__);
      $scanLog = $appRoot . '/storage/logs/bot-config-orphans-scan.log';
      $lastScanAt = is_file($scanLog) ? date('c', filemtime($scanLog)) : null;

      $settingsTableReady = (bool)$pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if ($settingsTableReady) {
        $stateStmt = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'bot_integrity.last_scan' LIMIT 1");
        $stateStmt->execute();
        $row = $stateStmt->fetch(PDO::FETCH_ASSOC);
        if ($row && !empty($row['setting_value'])) {
          $scanState = json_decode((string)$row['setting_value'], true);
          if (is_array($scanState) && !empty($scanState['ran_at'])) {
            $lastScanAt = (string)$scanState['ran_at'];
          }
        }
      }

      json([
        'ok' => true,
        'bot_integrity' => [
          'summary' => [
            'missing_partner_rows' => $missingPartnerRows,
            'missing_zone_rows' => $missingZoneRows,
            'mismatched_zone_partner_rows' => $mismatchedRows,
            'total_orphan_rows' => $missingPartnerRows + $missingZoneRows + $mismatchedRows,
          ],
          'last_scan_at' => $lastScanAt,
          'orphans' => $orphans,
          'table_ready' => true,
        ],
      ]);
    }

    // POST /admin/settings/health/bot-integrity/scan - Execute scan now (no cleanup)
    if ($method === 'POST' && $uri === '/admin/settings/health/bot-integrity/scan') {
      $appRoot = dirname(__DIR__);
      $scanScript = $appRoot . '/deploy/check-bot-config-orphans.php';
      if (!file_exists($scanScript)) {
        json(['ok' => false, 'error' => 'bot_integrity_scan_script_not_found'], 500);
      }

      $cmd = sprintf(
        'cd %s && php deploy/check-bot-config-orphans.php --json 2>&1',
        escapeshellarg($appRoot)
      );
      exec($cmd, $output, $returnCode);

      $jsonOutput = trim(implode("\n", $output));
      $parsed = json_decode($jsonOutput, true);

      if (!is_array($parsed)) {
        json([
          'ok' => false,
          'error' => 'scan_output_parse_failed',
          'status' => 'FAILED',
          'output' => $jsonOutput,
        ], 500);
      }

      $summary = $parsed['summary'] ?? [];
      $total = (int)($summary['total_orphan_rows'] ?? 0);
      $status = ($returnCode === 0 && $total === 0) ? 'PASSED' : 'FAILED';

      $scanState = [
        'ran_at' => date('c'),
        'status' => strtolower($status),
        'summary' => $summary,
      ];

      $settingsTableReady = (bool)$pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
      if ($settingsTableReady) {
        $setStmt = $pdo->prepare("INSERT INTO system_settings (setting_key, setting_value, setting_type)
          VALUES ('bot_integrity.last_scan', ?, 'json')
          ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()");
        $setStmt->execute([json_encode($scanState, JSON_UNESCAPED_SLASHES)]);
      }

      json([
        'ok' => $returnCode === 0,
        'status' => $status,
        'summary' => $summary,
        'scan' => $parsed,
        'last_scan' => $scanState,
      ]);
    }

    // GET /admin/settings/runtime-infra - Runtime infrastructure health + preferences
    if ($method === 'GET' && $uri === '/admin/settings/runtime-infra') {
      $rateStore = ct_rate_limit_store();
      $sessionDriver = strtolower(trim((string)($_ENV['CT_SESSION_DRIVER'] ?? 'file')));
      if (!in_array($sessionDriver, ['file', 'redis'], true)) {
        $sessionDriver = 'file';
      }

      // Keep precedence aligned with app/rate_limiter.php runtime behavior.
      $rateRedisUrl = trim((string)($_ENV['REDIS_URL'] ?? $_ENV['CT_RATE_LIMIT_REDIS_URL'] ?? ''));
      $sessionRedisUrl = trim((string)($_ENV['CT_SESSION_REDIS_URL'] ?? $_ENV['REDIS_URL'] ?? ''));
      $rateRedisPing = ct_ping_redis_url($rateRedisUrl);
      $sessionRedisPing = ct_ping_redis_url($sessionRedisUrl);

      $preferences = [
        'preferred_rate_limit_store' => system_settings_get($pdo, 'runtime.preferred_rate_limit_store', $rateStore),
        'preferred_session_driver' => system_settings_get($pdo, 'runtime.preferred_session_driver', $sessionDriver),
        'target_topology' => system_settings_get($pdo, 'runtime.target_topology', 'single-node'),
      ];

      json([
        'ok' => true,
        'runtime' => [
          'active' => [
            'rate_limit_store' => $rateStore,
            'session_driver' => $sessionDriver,
            'redis_extension_loaded' => class_exists('Redis'),
            'rate_limit_redis_connected' => $rateRedisPing['ok'],
            'session_redis_connected' => $sessionRedisPing['ok'],
            'rate_limit_redis_error' => $rateRedisPing['error'] ?? null,
            'session_redis_error' => $sessionRedisPing['error'] ?? null,
          ],
          'preferences' => $preferences,
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/runtime-infra - Save runtime infrastructure preferences
    if ($method === 'PUT' && $uri === '/admin/settings/runtime-infra') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      $allowedStores = ['file', 'redis'];
      $allowedTopologies = ['single-node', 'multi-node'];

      $preferredStore = strtolower(trim((string)($input['preferred_rate_limit_store'] ?? '')));
      $preferredSession = strtolower(trim((string)($input['preferred_session_driver'] ?? '')));
      $targetTopology = strtolower(trim((string)($input['target_topology'] ?? '')));

      if ($preferredStore !== '' && !in_array($preferredStore, $allowedStores, true)) {
        json(['ok' => false, 'error' => 'invalid_preferred_rate_limit_store'], 422);
      }
      if ($preferredSession !== '' && !in_array($preferredSession, $allowedStores, true)) {
        json(['ok' => false, 'error' => 'invalid_preferred_session_driver'], 422);
      }
      if ($targetTopology !== '' && !in_array($targetTopology, $allowedTopologies, true)) {
        json(['ok' => false, 'error' => 'invalid_target_topology'], 422);
      }

      if ($preferredStore !== '') {
        system_settings_upsert($pdo, 'runtime.preferred_rate_limit_store', $preferredStore, 'string');
      }
      if ($preferredSession !== '') {
        system_settings_upsert($pdo, 'runtime.preferred_session_driver', $preferredSession, 'string');
      }
      if ($targetTopology !== '') {
        system_settings_upsert($pdo, 'runtime.target_topology', $targetTopology, 'string');
      }

      json(['ok' => true]);
    }

    // GET /admin/settings/observability - Observability controls + status
    if ($method === 'GET' && $uri === '/admin/settings/observability') {
      $appRoot = dirname(__DIR__);
      $aggregateScript = $appRoot . '/deploy/aggregate-observability.php';
      $settings = [
        'lookback_days' => (int)system_settings_get($pdo, 'observability.lookback_days', 7),
        'auto_hourly_enabled' => (bool)system_settings_get($pdo, 'observability.auto_hourly_enabled', true),
        'retention_days' => (int)system_settings_get($pdo, 'observability.retention_days', 30),
        'last_run' => system_settings_get($pdo, 'observability.last_run', []),
      ];

      if (!is_array($settings['last_run'])) {
        $settings['last_run'] = [];
      }

      json([
        'ok' => true,
        'observability' => [
          'settings' => $settings,
          'script_present' => is_file($aggregateScript),
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/observability - Save observability control settings
    if ($method === 'PUT' && $uri === '/admin/settings/observability') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      $lookbackDays = isset($input['lookback_days']) ? (int)$input['lookback_days'] : null;
      $autoHourly = isset($input['auto_hourly_enabled']) ? (bool)$input['auto_hourly_enabled'] : null;
      $retentionDays = isset($input['retention_days']) ? (int)$input['retention_days'] : null;

      if ($lookbackDays !== null) {
        $lookbackDays = max(1, min(30, $lookbackDays));
        system_settings_upsert($pdo, 'observability.lookback_days', $lookbackDays, 'integer');
      }
      if ($autoHourly !== null) {
        system_settings_upsert($pdo, 'observability.auto_hourly_enabled', $autoHourly, 'boolean');
      }
      if ($retentionDays !== null) {
        $retentionDays = max(7, min(365, $retentionDays));
        system_settings_upsert($pdo, 'observability.retention_days', $retentionDays, 'integer');
      }

      json(['ok' => true]);
    }

    // POST /admin/settings/observability/run - Run aggregation now
    if ($method === 'POST' && $uri === '/admin/settings/observability/run') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/aggregate-observability.php';
      if (!is_file($script)) {
        json(['ok' => false, 'error' => 'aggregate_script_not_found'], 500);
      }

      $days = (int)system_settings_get($pdo, 'observability.lookback_days', 7);
      $days = max(1, min(30, $days));

      $cmd = sprintf(
        'cd %s && php deploy/aggregate-observability.php --days=%d 2>&1',
        escapeshellarg($appRoot),
        $days
      );
      exec($cmd, $output, $returnCode);

      $state = [
        'ran_at' => date('c'),
        'status' => $returnCode === 0 ? 'passed' : 'failed',
        'days' => $days,
        'exit_code' => $returnCode,
        'output_excerpt' => array_slice($output, -120),
      ];
      system_settings_upsert($pdo, 'observability.last_run', $state, 'json');

      json([
        'ok' => $returnCode === 0,
        'status' => strtoupper($state['status']),
        'last_run' => $state,
        'output' => implode("\n", $output),
      ]);
    }

    // GET /admin/settings/security-logging - Security logging controls + status
    if ($method === 'GET' && $uri === '/admin/settings/security-logging') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/verify-security-logging.php';
      $settings = [
        'retention_days' => (int)system_settings_get($pdo, 'security_logging.retention_days', 90),
        'verify_daily_enabled' => (bool)system_settings_get($pdo, 'security_logging.verify_daily_enabled', true),
        'last_verification' => system_settings_get($pdo, 'security_logging.last_verification', []),
      ];
      if (!is_array($settings['last_verification'])) {
        $settings['last_verification'] = [];
      }

      json([
        'ok' => true,
        'security_logging' => [
          'settings' => $settings,
          'script_present' => is_file($script),
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/security-logging - Save security logging controls
    if ($method === 'PUT' && $uri === '/admin/settings/security-logging') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      if (isset($input['retention_days'])) {
        $retentionDays = max(7, min(365, (int)$input['retention_days']));
        system_settings_upsert($pdo, 'security_logging.retention_days', $retentionDays, 'integer');
      }
      if (isset($input['verify_daily_enabled'])) {
        system_settings_upsert($pdo, 'security_logging.verify_daily_enabled', (bool)$input['verify_daily_enabled'], 'boolean');
      }

      json(['ok' => true]);
    }

    // POST /admin/settings/security-logging/verify - Run security logging verification now
    if ($method === 'POST' && $uri === '/admin/settings/security-logging/verify') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/verify-security-logging.php';
      if (!is_file($script)) {
        json(['ok' => false, 'error' => 'security_logging_script_not_found'], 500);
      }

      $cmd = sprintf(
        'cd %s && php deploy/verify-security-logging.php 2>&1',
        escapeshellarg($appRoot)
      );
      exec($cmd, $output, $returnCode);

      $cleanOutput = array_map(static function ($line): string {
        $text = (string)$line;
        // Strip ANSI color/control sequences for UI readability.
        return preg_replace('/\e\[[0-9;]*[A-Za-z]/', '', $text) ?? $text;
      }, $output);

      $failedChecks = [];
      foreach ($cleanOutput as $line) {
        $trimmed = trim((string)$line);
        if ($trimmed === '') {
          continue;
        }
        if (str_contains($trimmed, '✗')) {
          $failedChecks[] = $trimmed;
          continue;
        }
        if (preg_match('/^Failed:\s*[1-9][0-9]*/i', $trimmed)) {
          $failedChecks[] = $trimmed;
        }
      }
      $failedChecks = array_values(array_unique($failedChecks));

      $state = [
        'ran_at' => date('c'),
        'status' => $returnCode === 0 ? 'passed' : 'failed',
        'exit_code' => $returnCode,
        'failed_checks' => $failedChecks,
        'output_excerpt' => array_slice($cleanOutput, -120),
      ];
      system_settings_upsert($pdo, 'security_logging.last_verification', $state, 'json');

      json([
        'ok' => $returnCode === 0,
        'status' => strtoupper($state['status']),
        'last_verification' => $state,
        'output' => implode("\n", $cleanOutput),
      ]);
    }

    // GET /admin/settings/docs-governance - Docs governance controls + status
    if ($method === 'GET' && $uri === '/admin/settings/docs-governance') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/verify-doc-consistency.php';
      $settings = [
        'strict_mode' => (bool)system_settings_get($pdo, 'docs_governance.strict_mode', false),
        'monthly_check_enabled' => (bool)system_settings_get($pdo, 'docs_governance.monthly_check_enabled', true),
        'last_check' => system_settings_get($pdo, 'docs_governance.last_check', []),
      ];
      if (!is_array($settings['last_check'])) {
        $settings['last_check'] = [];
      }

      json([
        'ok' => true,
        'docs_governance' => [
          'settings' => $settings,
          'script_present' => is_file($script),
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/docs-governance - Save docs governance controls
    if ($method === 'PUT' && $uri === '/admin/settings/docs-governance') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      if (isset($input['strict_mode'])) {
        system_settings_upsert($pdo, 'docs_governance.strict_mode', (bool)$input['strict_mode'], 'boolean');
      }
      if (isset($input['monthly_check_enabled'])) {
        system_settings_upsert($pdo, 'docs_governance.monthly_check_enabled', (bool)$input['monthly_check_enabled'], 'boolean');
      }

      json(['ok' => true]);
    }

    // POST /admin/settings/docs-governance/check - Run docs consistency check now
    if ($method === 'POST' && $uri === '/admin/settings/docs-governance/check') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/verify-doc-consistency.php';
      if (!is_file($script)) {
        json(['ok' => false, 'error' => 'docs_consistency_script_not_found'], 500);
      }

      $strictMode = (bool)system_settings_get($pdo, 'docs_governance.strict_mode', false);
      $cmd = sprintf(
        'cd %s && php deploy/verify-doc-consistency.php%s 2>&1',
        escapeshellarg($appRoot),
        $strictMode ? ' --strict' : ''
      );
      exec($cmd, $output, $returnCode);

      $cleanOutput = array_map(static function ($line): string {
        $text = (string)$line;
        return preg_replace('/\e\[[0-9;]*[A-Za-z]/', '', $text) ?? $text;
      }, $output);
      $failedChecks = [];
      foreach ($cleanOutput as $line) {
        $trimmed = trim((string)$line);
        if ($trimmed === '') {
          continue;
        }
        if (str_contains($trimmed, '✗')) {
          $failedChecks[] = $trimmed;
        }
      }
      $failedChecks = array_values(array_unique($failedChecks));

      $state = [
        'ran_at' => date('c'),
        'strict_mode' => $strictMode,
        'status' => $returnCode === 0 ? 'passed' : 'failed',
        'exit_code' => $returnCode,
        'failed_checks' => $failedChecks,
        'output_excerpt' => array_slice($cleanOutput, -120),
      ];
      system_settings_upsert($pdo, 'docs_governance.last_check', $state, 'json');

      json([
        'ok' => $returnCode === 0,
        'status' => strtoupper($state['status']),
        'last_check' => $state,
        'output' => implode("\n", $cleanOutput),
      ]);
    }

    // GET /admin/settings/bot-policy - Bot defense policy controls + status
    if ($method === 'GET' && $uri === '/admin/settings/bot-policy') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/analyze-bot-abuse.php';

      $settings = [
        'analysis_hours' => (int)system_settings_get($pdo, 'bot_policy.analysis_hours', 24),
        'block_threshold' => (int)system_settings_get($pdo, 'bot_policy.block_threshold', 50),
        'dry_run_enabled' => (bool)system_settings_get($pdo, 'bot_policy.dry_run_enabled', true),
        'last_report' => system_settings_get($pdo, 'bot_policy.last_report', []),
      ];
      if (!is_array($settings['last_report'])) {
        $settings['last_report'] = [];
      }

      json([
        'ok' => true,
        'bot_policy' => [
          'settings' => $settings,
          'script_present' => is_file($script),
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/bot-policy - Save bot policy controls
    if ($method === 'PUT' && $uri === '/admin/settings/bot-policy') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      if (isset($input['analysis_hours'])) {
        $hours = max(1, min(168, (int)$input['analysis_hours']));
        system_settings_upsert($pdo, 'bot_policy.analysis_hours', $hours, 'integer');
      }
      if (isset($input['block_threshold'])) {
        $threshold = max(1, min(5000, (int)$input['block_threshold']));
        system_settings_upsert($pdo, 'bot_policy.block_threshold', $threshold, 'integer');
      }
      if (isset($input['dry_run_enabled'])) {
        system_settings_upsert($pdo, 'bot_policy.dry_run_enabled', (bool)$input['dry_run_enabled'], 'boolean');
      }

      json(['ok' => true]);
    }

    // POST /admin/settings/bot-policy/run - Run bot abuse analysis now
    if ($method === 'POST' && $uri === '/admin/settings/bot-policy/run') {
      $appRoot = dirname(__DIR__);
      $script = $appRoot . '/deploy/analyze-bot-abuse.php';
      if (!is_file($script)) {
        json(['ok' => false, 'error' => 'bot_abuse_script_not_found'], 500);
      }

      $hours = max(1, min(168, (int)system_settings_get($pdo, 'bot_policy.analysis_hours', 24)));
      $threshold = max(1, min(5000, (int)system_settings_get($pdo, 'bot_policy.block_threshold', 50)));
      $dryRun = (bool)system_settings_get($pdo, 'bot_policy.dry_run_enabled', true);

      $cmd = sprintf(
        'cd %s && php deploy/analyze-bot-abuse.php --mode=report --hours=%d --threshold=%d%s 2>&1',
        escapeshellarg($appRoot),
        $hours,
        $threshold,
        $dryRun ? ' --dry-run' : ''
      );
      exec($cmd, $output, $returnCode);

      $cleanOutput = array_map(static function ($line): string {
        $text = (string)$line;
        return preg_replace('/\e\[[0-9;]*[A-Za-z]/', '', $text) ?? $text;
      }, $output);
      $failedChecks = [];
      foreach ($cleanOutput as $line) {
        $trimmed = trim((string)$line);
        if ($trimmed === '') {
          continue;
        }
        if (str_contains($trimmed, '✗') || str_contains(strtoupper($trimmed), 'FAILED')) {
          $failedChecks[] = $trimmed;
        }
      }
      $failedChecks = array_values(array_unique($failedChecks));

      $state = [
        'ran_at' => date('c'),
        'status' => $returnCode === 0 ? 'passed' : 'failed',
        'hours' => $hours,
        'threshold' => $threshold,
        'dry_run' => $dryRun,
        'exit_code' => $returnCode,
        'failed_checks' => $failedChecks,
        'output_excerpt' => array_slice($cleanOutput, -120),
      ];
      system_settings_upsert($pdo, 'bot_policy.last_report', $state, 'json');

      json([
        'ok' => $returnCode === 0,
        'status' => strtoupper($state['status']),
        'last_report' => $state,
        'output' => implode("\n", $cleanOutput),
      ]);
    }

    // GET /admin/settings/deployment-preflight - Deployment preflight controls + latest status
    if ($method === 'GET' && $uri === '/admin/settings/deployment-preflight') {
      $settings = [
        'run_error_leakage_check' => (bool)system_settings_get($pdo, 'deployment_preflight.run_error_leakage_check', true),
        'run_endpoint_matrix_check' => (bool)system_settings_get($pdo, 'deployment_preflight.run_endpoint_matrix_check', true),
        'run_docs_consistency_check' => (bool)system_settings_get($pdo, 'deployment_preflight.run_docs_consistency_check', true),
        'strict_docs_check' => (bool)system_settings_get($pdo, 'deployment_preflight.strict_docs_check', false),
        'last_run' => system_settings_get($pdo, 'deployment_preflight.last_run', []),
      ];

      if (!is_array($settings['last_run'])) {
        $settings['last_run'] = [];
      }

      json([
        'ok' => true,
        'deployment_preflight' => [
          'settings' => $settings,
          'table_ready' => system_settings_table_ready($pdo),
        ],
      ]);
    }

    // PUT /admin/settings/deployment-preflight - Save deployment preflight control settings
    if ($method === 'PUT' && $uri === '/admin/settings/deployment-preflight') {
      $input = json_decode(file_get_contents('php://input'), true) ?: [];

      $keys = [
        'run_error_leakage_check',
        'run_endpoint_matrix_check',
        'run_docs_consistency_check',
        'strict_docs_check',
      ];
      foreach ($keys as $key) {
        if (array_key_exists($key, $input)) {
          system_settings_upsert($pdo, 'deployment_preflight.' . $key, (bool)$input[$key], 'boolean');
        }
      }

      json(['ok' => true]);
    }

    // POST /admin/settings/deployment-preflight/run - Run preflight bundle now
    if ($method === 'POST' && $uri === '/admin/settings/deployment-preflight/run') {
      $appRoot = dirname(__DIR__);
      $checks = [];

      $runErrorLeakage = (bool)system_settings_get($pdo, 'deployment_preflight.run_error_leakage_check', true);
      $runEndpointMatrix = (bool)system_settings_get($pdo, 'deployment_preflight.run_endpoint_matrix_check', true);
      $runDocsConsistency = (bool)system_settings_get($pdo, 'deployment_preflight.run_docs_consistency_check', true);
      $strictDocs = (bool)system_settings_get($pdo, 'deployment_preflight.strict_docs_check', false);

      $runCheck = function (string $name, string $command) use (&$checks, $appRoot): void {
        $cmd = sprintf('cd %s && %s 2>&1', escapeshellarg($appRoot), $command);
        $output = [];
        $rc = 1;
        exec($cmd, $output, $rc);

        $checks[] = [
          'name' => $name,
          'status' => $rc === 0 ? 'passed' : 'failed',
          'exit_code' => $rc,
          'output_excerpt' => array_slice($output, -80),
        ];
      };

      if ($runErrorLeakage) {
        $runCheck('error_leakage', 'php deploy/verify-no-error-leakage.php');
      }
      if ($runEndpointMatrix) {
        $runCheck('endpoint_security_matrix', 'php deploy/verify-endpoint-security-matrix.php');
      }
      if ($runDocsConsistency) {
        $runCheck('docs_consistency', 'php deploy/verify-doc-consistency.php' . ($strictDocs ? ' --strict' : ''));
      }

      $overallPassed = !empty($checks) && array_reduce($checks, static function (bool $carry, array $check): bool {
        return $carry && (($check['status'] ?? 'failed') === 'passed');
      }, true);

      $state = [
        'ran_at' => date('c'),
        'status' => $overallPassed ? 'passed' : 'failed',
        'checks' => $checks,
      ];

      $failedChecks = [];
      foreach ($checks as $check) {
        if (($check['status'] ?? 'failed') !== 'failed') {
          continue;
        }
        $name = (string)($check['name'] ?? 'check');
        $failedChecks[] = $name . ' failed';
        $excerpt = is_array($check['output_excerpt'] ?? null) ? $check['output_excerpt'] : [];
        foreach ($excerpt as $line) {
          $trimmed = trim((string)$line);
          if ($trimmed !== '') {
            $failedChecks[] = $name . ': ' . $trimmed;
          }
        }
      }
      $state['failed_checks'] = array_values(array_unique($failedChecks));

      system_settings_upsert($pdo, 'deployment_preflight.last_run', $state, 'json');

      json([
        'ok' => $overallPassed,
        'status' => strtoupper($state['status']),
        'checks' => $checks,
        'last_run' => $state,
      ]);
    }
    
    json(['error' => 'not_found'], 404);
  }
  // ==== END SETTINGS API ====

  // GET /admin/dashboard/stats
  // Get platform-wide statistics for home dashboard (available to all authenticated users)
  if ($uri === '/admin/dashboard/stats' && $method === 'GET') {
    $auth = require_session_auth();
    
    try {
      $pdo = db();
      
      // Count entities
      $stats = [];
      $stats['partners'] = (int)$pdo->query("SELECT COUNT(*) FROM partners")->fetchColumn();
      $stats['zones'] = (int)$pdo->query("SELECT COUNT(*) FROM partner_zones")->fetchColumn();
      $stats['advertisers'] = (int)$pdo->query("SELECT COUNT(*) FROM advertisers")->fetchColumn();
      $stats['campaigns'] = (int)$pdo->query("SELECT COUNT(*) FROM campaigns")->fetchColumn();
      $stats['ad_items'] = (int)$pdo->query("SELECT COUNT(*) FROM ad_items")->fetchColumn();
      
      // Impressions and clicks by time periods using actual table names
      $stats['impressions_yesterday'] = (int)$pdo->query("SELECT COUNT(*) FROM impression_logs WHERE ts >= CURDATE() - INTERVAL 1 DAY")->fetchColumn();
      $stats['impressions_30d'] = (int)$pdo->query("SELECT COUNT(*) FROM impression_logs WHERE ts >= CURDATE() - INTERVAL 30 DAY")->fetchColumn();
      $stats['impressions_year'] = (int)$pdo->query("SELECT COUNT(*) FROM impression_logs WHERE YEAR(ts) = YEAR(CURDATE())")->fetchColumn();
      
      $stats['clicks_yesterday'] = (int)$pdo->query("SELECT COUNT(*) FROM click_logs WHERE ts >= CURDATE() - INTERVAL 1 DAY")->fetchColumn();
      $stats['clicks_30d'] = (int)$pdo->query("SELECT COUNT(*) FROM click_logs WHERE ts >= CURDATE() - INTERVAL 30 DAY")->fetchColumn();
      $stats['clicks_year'] = (int)$pdo->query("SELECT COUNT(*) FROM click_logs WHERE YEAR(ts) = YEAR(CURDATE())")->fetchColumn();
      
      json([
        'ok' => true,
        'stats' => $stats
      ]);
    } catch (Exception $e) {
      safe_error($e, 'database_error', 'Dashboard stats error');
    }
    return;
  }

// ---------- Admin gate ----------
if (str_starts_with($uri, '/admin')) {
  // Set CSP header to allow blob URLs for image previews in admin UI
  header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; upgrade-insecure-requests; block-all-mixed-content");

  if (($_GET['debug'] ?? '') === '1') { json(['dbg'=>['method'=>$method,'uri'=>$uri]], 200); }
  if (($_GET['debug'] ?? '') === '2') { json(['dbg'=>['method'=>$method,'uri'=>$uri,'dest'=>dest_col(),'cols'=>array_keys(tracking_columns())]], 200); }
  rate_limit('admin:'.client_ip(), 60, 60);


// Accept EITHER a valid ADMIN_TOKEN bearer OR an admin session
$authz = ct_auth_authorize_admin_or_session(['admin','superadmin']);
$hasToken = (bool)($authz['has_token'] ?? false);
$auth = $authz['auth'] ?? null;
if (!(bool)($authz['authorized'] ?? false)) {
  $reason = (string)($authz['reason'] ?? 'auth_required');
  if ($reason === 'forbidden') {
    json(['error' => 'forbidden'], 403);
  }
  json(['error' => 'auth_required'], 401);
}

// Ensure $auth is available even if a bearer token was used.
// For sensitive actions (like requesting superadmin), a session is required.
if (!isset($auth)) {
  ct_start_auth_session();
  $auth = $_SESSION['auth'] ?? null;
}

// Require CSRF for all mutating /admin routes when using session auth.
require_admin_csrf_for_mutation($hasToken, $method);

// ----- superadmin approval helpers -----
function sa_is_superadmin($auth) {
  return is_array($auth) && (($auth['role'] ?? '') === 'superadmin');
}
function sa_is_approver_13($auth) {
  return sa_is_superadmin($auth) && (int)($auth['id'] ?? 0) === 13;
}
function sa_find_or_create_request(PDO $pdo, int $targetUserId, int $requesterId): array {
  // If there is already a pending request for this user, return it
  $st = $pdo->prepare('SELECT id,status,created_at FROM superadmin_requests WHERE user_id = ? AND status = "pending" LIMIT 1');
  $st->execute([$targetUserId]);
  $row = $st->fetch();
  if ($row) {
    return ['id'=>(int)$row['id'], 'status'=>$row['status'], 'created_at'=>$row['created_at']];
  }
  // Otherwise create a new pending request
  $st = $pdo->prepare('INSERT INTO superadmin_requests (user_id, requester_id, status, created_at) VALUES (?, ?, "pending", NOW())');
  $st->execute([$targetUserId, $requesterId]);
  return ['id' => (int)$pdo->lastInsertId(), 'status' => 'pending', 'created_at' => date('Y-m-d H:i:s')];
}

  
  // /admin.json
  if ($uri === '/admin.json' && $method === 'GET') {
    json([
      'name' => 'ClickTrack+',
      'version' => '0.2.0',
      'capabilities' => [
        'utm_fields' => true,
        'utm_policy' => ['append_if_missing','override_always','ignore'],
        'export' => ['clicks','impressions'],
        'rate_limited' => true,
      ]
    ]);
  }

    // GET /admin/tags/canary  (Bearer-protected; read-only feature probe)
    if ($uri === '/admin/tags/canary' && $method === 'GET') {
      if (!ct_auth_has_valid_admin_token()) {
        json(['error' => 'forbidden'], 403);
      }
      $enable = (($_ENV['CT_ENABLE_TAGS'] ?? '0') === '1') ? 1 : 0;
      $debug  = (($_ENV['CT_TAG_DEBUG']   ?? '0') === '1') ? 1 : 0;
      $signing_set = !empty($_ENV['CT_TAG_SIGNING_KEY']);
      json([
        'ok' => true,
        'enable' => $enable,
        'debug' => $debug,
        'signing_key_set' => $signing_set ? true : false,
        'algo' => 'hmac-sha256'
      ], 200);
    }


// POST /admin/users  (create or update by email — merged canonical handler)
if ($method === 'POST' && $uri === '/admin/users') {
  require_session_auth(['admin', 'superadmin']);
  header('X-Users-Route: post');
  header('Content-Type: application/json; charset=utf-8');

  $inRaw = file_get_contents('php://input') ?: '';
  $in    = json_decode($inRaw, true);
  if (!is_array($in)) { $in = $_POST ?: []; }
  if (function_exists('dbg_put')) { dbg_put('[POST /admin/users] payload:', $in); }

  $email = strtolower(trim((string)($in['email'] ?? '')));
  $name  = trim((string)($in['name']  ?? ''));
  $role  = (string)($in['role'] ?? 'employee');     // 'employee' | 'admin' | 'superadmin'
  $pass  = (string)($in['password'] ?? '');

  // Basic validation
  if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    json(['error' => 'bad_email'], 422);
  }
  if ($name === '') {
    json(['error' => 'email_and_name_required'], 422);
  }
  if (!in_array($role, ['superadmin','admin','employee'], true)) {
    json(['error' => 'bad_role'], 422);
  }

  $pdo = db();

  // Does user exist?
  $st  = $pdo->prepare('SELECT id, role FROM users WHERE email = ? LIMIT 1');
  $st->execute([$email]);
  $row     = $st->fetch(PDO::FETCH_ASSOC) ?: null;
  $exists  = (bool)$row;
  $userId  = $exists ? (int)$row['id'] : null;

  // On create, password is required (and must be >= 8)
  if (!$exists) {
    if ($pass === '') { json(['error' => 'password_required_for_create'], 422); }
    if (strlen($pass) < 8) { json(['error' => 'password_too_short'], 422); }
  } else {
    // On update: if password is provided, enforce length; if empty string, ignore
    if ($pass !== '' && strlen($pass) < 8) {
      json(['error' => 'password_too_short'], 422);
    }
  }

  // --- Superadmin elevation policy (request + approval flow) ---
  // Only an existing superadmin may directly assign 'superadmin'.
  // Others: open/ensure a pending request and (a) for existing user: proceed with role downgraded to 'admin';
  // (b) for new user: create as 'admin' and return 202 with 'pending:true'.
  $reqRole = $auth['role'] ?? null;
  $reqId   = (int)($auth['id'] ?? 0);

  if ($role === 'superadmin' && $reqRole !== 'superadmin') {
    // Must be logged in (session) to attribute the requester
    if (!$auth || !in_array(($auth['role'] ?? ''), ['admin','superadmin'], true)) {
      json(['error' => 'login_required_for_superadmin_request'], 401);
    }

    if ($exists) {
      // Existing user: open/ensure request and continue as 'admin' (no elevation yet)
      $req = sa_find_or_create_request($pdo, $userId, $reqId);
      $role = 'admin';
      // fall through to normal UPDATE below
    } else {
      // New user: create as 'admin', open request, and return 202 pending
      $safeRole = 'admin';
      $ph = password_hash($pass, PASSWORD_DEFAULT);
      $pdo->prepare('INSERT INTO users (email,name,password_hash,role,created_at,updated_at) VALUES (?,?,?,?,NOW(),NOW())')
          ->execute([$email, $name, $ph, $safeRole]);
      $newId = (int)$pdo->lastInsertId();

      $req = sa_find_or_create_request($pdo, $newId, $reqId);
      json([
        'ok'      => true,
        'created' => true,
        'pending' => true,
        'request' => ['id' => $req['id'], 'status' => $req['status']],
        'user'    => ['id'=>$newId,'email'=>$email,'name'=>$name,'role'=>$safeRole],
        'note'    => 'User created as admin. Superadmin elevation requires approval by any current superadmin.'
      ], 202);
    }
  }
  // --- End superadmin elevation policy ---

  if ($exists) {
    // UPDATE path
    $fields = ['name = :name', 'role = :role', 'updated_at = NOW()'];
    $params = [':name'=>$name, ':role'=>$role, ':id'=>$userId];

    if ($pass !== '') {
      $fields[]       = 'password_hash = :ph';
      $params[':ph']  = password_hash($pass, PASSWORD_DEFAULT);
    }

    $sql = 'UPDATE users SET '.implode(', ', $fields).' WHERE id = :id';
    $pdo->prepare($sql)->execute($params);

    // Return the saved row
    $st2 = $pdo->prepare('SELECT id,email,name,role,created_at,updated_at FROM users WHERE id = ? LIMIT 1');
    $st2->execute([$userId]);
    $out = $st2->fetch(PDO::FETCH_ASSOC) ?: null;

    json(['ok'=>true, 'updated'=>true, 'user'=>$out], 200);
  } else {
    // CREATE path
    $ph = password_hash($pass, PASSWORD_DEFAULT);
    $sql = 'INSERT INTO users (email,name,role,password_hash,created_at,updated_at)
            VALUES (:email,:name,:role,:ph,NOW(),NOW())';
    $pdo->prepare($sql)->execute([
      ':email'=>$email, ':name'=>$name, ':role'=>$role, ':ph'=>$ph
    ]);

    $newId = (int)$pdo->lastInsertId();
    $st2 = $pdo->prepare('SELECT id,email,name,role,created_at,updated_at FROM users WHERE id = ? LIMIT 1');
    $st2->execute([$newId]);
    $out = $st2->fetch(PDO::FETCH_ASSOC) ?: null;

    http_response_code(201);
    json(['ok'=>true, 'created'=>true, 'user'=>$out], 201);
  }
}


  // PUT /admin/users/{id}  (update name/role/password)
  if ($method === 'PUT' && preg_match('#^/admin/users/([0-9]+)$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    $id = (int)$m[1];
    if ($id < 1) { json(['error'=>'bad_id'], 422); }

    $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: [];

    // Parse fields first so we can validate/evaluate intent
    $name = array_key_exists('name', $in) ? trim((string)$in['name']) : null;
    $role = array_key_exists('role', $in) ? (string)$in['role'] : null;
    $pw   = array_key_exists('password', $in) ? (string)$in['password'] : null;

    if ($role !== null && !in_array($role, ['superadmin','admin','employee'], true)) {
      json(['error'=>'invalid_role'], 422);
    }

    // Elevation to superadmin: require existing superadmin; otherwise create a pending request and return 202
    $reqRole = $auth['role'] ?? null;
    $reqId   = (int)($auth['id'] ?? 0);
    if ($role === 'superadmin' && $reqRole !== 'superadmin') {
      $pdo = db();

      // Ensure target exists
      $chk = $pdo->prepare('SELECT id FROM users WHERE id = ? LIMIT 1');
      $chk->execute([$id]);
      if (!$chk->fetch()) { json(['error' => 'not_found'], 404); }

      $req = sa_find_or_create_request($pdo, $id, $reqId);
      json([
        'pending'     => true,
        'request'     => $req,
        'note'        => 'Superadmin change requires approval by any current superadmin.'
      ], 202);
    }

    $fields = [];
    $params = [];

    if ($name !== null) { $fields[] = 'name = ?'; $params[] = $name; }
    if ($role !== null) { $fields[] = 'role = ?'; $params[] = $role; }
    if ($pw !== null && $pw !== '') {
      if (strlen($pw) < 8) { json(['error'=>'password_too_short'], 422); }
      $fields[] = 'password_hash = ?'; $params[] = password_hash($pw, PASSWORD_DEFAULT);
    }

    if (!$fields) { json(['ok'=>true, 'updated'=>false, 'id'=>$id], 200); }

    $fields[] = 'updated_at = NOW()';
    $sql = 'UPDATE users SET '.implode(', ', $fields).' WHERE id = ?';
    $params[] = $id;

    $st = db()->prepare($sql);
    $st->execute($params);

    json(['ok'=>true, 'updated'=>true, 'id'=>$id], 200);
  }






// DELETE /admin/users/{id}
if ($method === 'DELETE' && preg_match('#^/admin/users/([0-9]+)$#', $uri, $m)) {
  require_session_auth(['admin', 'superadmin']);
  $id = (int)$m[1];
  if ($id < 1) { json(['error'=>'bad_id'], 422); }

  // optionally prevent deleting self:
  // if (($auth['id'] ?? 0) === $id) { json(['error'=>'cannot_delete_self'], 400); }

  $st = db()->prepare('DELETE FROM users WHERE id = ?');
  $st->execute([$id]);

  json(['ok'=>true, 'id'=>$id]);
}

  // POST /admin/users/{id}/regenerate-token  (regenerate bearer token for user)
  if ($method === 'POST' && preg_match('#^/admin/users/([0-9]+)/regenerate-token$#', $uri, $m)) {
    // Require authentication (session or bearer token)
    if (!isset($auth) || !is_array($auth)) {
      json(['error' => 'auth_required'], 401);
    }
    
    $targetUserId = (int)$m[1];
    $currentUserId = (int)($auth['id'] ?? 0);
    $currentRole = (string)($auth['role'] ?? '');
    
    if ($targetUserId < 1) {
      json(['error'=>'bad_id'], 422);
    }
    
    // Users can only regenerate their own token unless they're superadmin
    if ($targetUserId !== $currentUserId && $currentRole !== 'superadmin') {
      json(['error'=>'forbidden'], 403);
    }
    
    $pdo = db();
    
    // Verify target user exists and is admin/superadmin
    $stmt = $pdo->prepare('SELECT id, role FROM users WHERE id = ? LIMIT 1');
    $stmt->execute([$targetUserId]);
    $targetUser = $stmt->fetch(PDO::FETCH_ASSOC);
    
    if (!$targetUser) {
      json(['error'=>'not_found'], 404);
    }
    
    if (!in_array($targetUser['role'], ['admin', 'superadmin'], true)) {
      json(['error'=>'invalid_role'], 422);
    }
    
    // Generate new bearer token (64 hex characters = 256 bits entropy)
    $newToken = bin2hex(random_bytes(32));
    
    // Set expiration to 30 days from now
    $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
    
    // Update token in database
    $updateStmt = $pdo->prepare('
      UPDATE users 
      SET bearer_token = ?, token_expires_at = ?, updated_at = NOW()
      WHERE id = ?
    ');
    $updateStmt->execute([$newToken, $expiresAt, $targetUserId]);
    
    json([
      'ok' => true,
      'token' => $newToken,
      'expires_at' => $expiresAt,
      'user_id' => $targetUserId
    ]);
  }



  // POST /admin/superadmin-requests/{rid}/approve  (session-only; any superadmin)
  if ($method === 'POST' && preg_match('#^/admin/superadmin-requests/([0-9]+)/approve$#', $uri, $m)) {
    header('X-SA-Approve: hit');

    // Require authenticated session and superadmin role
    if (!isset($auth) || !sa_is_superadmin($auth)) {
      json(['error' => 'forbidden'], 403);
    }

    $rid = (int)$m[1];
    if ($rid < 1) { json(['error'=>'bad_id'], 422); }

    $pdo = db();

    // Load request and ensure it's pending
    $st = $pdo->prepare('SELECT id,user_id,status FROM superadmin_requests WHERE id = ? LIMIT 1');
    $st->execute([$rid]);
    $req = $st->fetch(PDO::FETCH_ASSOC);
    if (!$req)                 { json(['error'=>'not_found'], 404); }
    if (($req['status'] ?? '') !== 'pending') { json(['error'=>'invalid_state'], 409); }

    $uid = (int)$req['user_id'];
    if ($uid < 1) { json(['error'=>'bad_user'], 422); }

    // Approve: elevate user, mark request approved
    $pdo->beginTransaction();
    try {
      $u1 = $pdo->prepare("UPDATE users SET role = 'superadmin', updated_at = NOW() WHERE id = ?");
      $u1->execute([$uid]);

      $u2 = $pdo->prepare("UPDATE superadmin_requests SET status = 'approved' WHERE id = ?");
      $u2->execute([$rid]);

      $pdo->commit();
    } catch (Throwable $e) {
      $pdo->rollBack();
      json(['error'=>'approve_failed'], 500);
    }

    json(['ok'=>true, 'request_id'=>$rid, 'user_id'=>$uid], 200);
  }

  // GET /admin/superadmin-requests/pending/count  (session superadmin only)
  if ($method === 'GET' && $uri === '/admin/superadmin-requests/pending/count') {
    if (!isset($auth) || ($auth['role'] ?? '') !== 'superadmin') {
      json(['error' => 'forbidden'], 403);
    }
    $pdo = db();
    $st = $pdo->query("SELECT COUNT(*) AS c FROM superadmin_requests WHERE status = 'pending'");
    $pending = (int)($st->fetch()['c'] ?? 0);
    json(['pending_count' => $pending], 200);
  }

  // GET /admin/superadmin-requests  (session superadmin only)
  if ($method === 'GET' && $uri === '/admin/superadmin-requests') {
    // Require an authenticated session and superadmin
    if (!isset($auth) || ($auth['role'] ?? '') !== 'superadmin') {
      json(['error' => 'forbidden'], 403);
    }

    $limit  = isset($_GET['limit'])  ? max(1, min(200, (int)$_GET['limit'])) : 50;
    $offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;

    $pdo = db();

    $stc = $pdo->query("SELECT COUNT(*) AS c FROM superadmin_requests");
    $total = (int)($stc->fetch()['c'] ?? 0);

    $st = $pdo->prepare("SELECT id,user_id,requester_id,status,created_at
                         FROM superadmin_requests
                         ORDER BY id DESC
                         LIMIT :lim OFFSET :off");
    $st->bindValue(':lim',  $limit,  PDO::PARAM_INT);
    $st->bindValue(':off',  $offset, PDO::PARAM_INT);
    $st->execute();
    $rows = $st->fetchAll();

    json([
      'count'    => $total,
      'items'    => $rows,
      'limit'    => $limit,
      'offset'   => $offset,
      'has_more' => ($offset + count($rows) < $total),
    ]);
  }





  // GET /admin/users
  if (
    $method === 'GET' &&
    ($uri === '/admin/users' || preg_match('#^/admin/users(?:/)?$#', $uri))
  ) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Users-Route: hit'); // <-- debug header so we can SEE it matched
    $limit  = isset($_GET['limit'])  ? max(1, min(200, (int)$_GET['limit'])) : 50;
    $offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
    $q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
    $where = ''; $params = [];
    if ($q !== '') {
      $like = '%'.$q.'%';
      $where = 'WHERE email LIKE :q OR name LIKE :q';
      $params[':q'] = $like;
    }

    $pdo = db();

    // total
    $stc = $pdo->prepare("SELECT COUNT(*) c FROM users $where");
    foreach ($params as $k=>$v) { $stc->bindValue($k, $v, PDO::PARAM_STR); }
    $stc->execute();
    $total = (int)($stc->fetch()['c'] ?? 0);

    // page
    $st = $pdo->prepare("SELECT id,email,name,role,created_at,updated_at,token_expires_at FROM users $where ORDER BY id DESC LIMIT :lim OFFSET :off");
    foreach ($params as $k=>$v) { $st->bindValue($k, $v, PDO::PARAM_STR); }
    $st->bindValue(':lim', $limit, PDO::PARAM_INT);
    $st->bindValue(':off', $offset, PDO::PARAM_INT);
    $st->execute();
    $rows = $st->fetchAll();

    json([
      'count'    => $total,
      'items'    => $rows,
      'limit'    => $limit,
      'offset'   => $offset,
      'has_more' => ($offset + count($rows) < $total),
      'q'        => $q,
    ]);
  }




// GET /admin/tracking
if ($uri === '/admin/tracking' && $method === 'GET') {
  $limit  = isset($_GET['limit'])  ? max(1, min(200, (int)$_GET['limit'])) : 50;
  $offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;

  $dest = dest_col();
  $destSelect = $dest ? "$dest AS destination" : "NULL AS destination";
  $cols = tracking_columns();
  $hasZone = isset($cols['zone_id']);
  $hasAd   = isset($cols['ad_item_id']);

  $pdo = db();
    $q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
    $params = [];

    // Build AND-able conditions
    $and = [];

    if ($q !== '') {
      $like = '%'.$q.'%';
      $searchCols = ['utm_source','utm_medium','utm_campaign','utm_content'];
      $or = [];
      foreach ($searchCols as $i => $c) { $or[] = "$c LIKE :q$i"; $params[":q$i"] = $like; }
      if ($dest) { $or[] = "$dest LIKE :qd"; $params[':qd'] = $like; }
      // allow searching by id if q is digits
      if (ctype_digit($q)) { $or[] = "id = :qid"; $params[':qid'] = (int)$q; }
      $and[] = '(' . implode(' OR ', $or) . ')';
    }

    // Optional exact filters
    if ($hasAd && isset($_GET['ad_item_id']) && $_GET['ad_item_id'] !== '') {
      $raw = trim((string)$_GET['ad_item_id']);
      if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_ad_item_id'], 422); }
      $and[] = 'ad_item_id = :adid';
      $params[':adid'] = (int)$raw;
    }

    // Optional list filter: ad_item_ids=1,2,3
    $adItemIds = [];
    if (isset($_GET['ad_item_ids']) && $_GET['ad_item_ids'] !== '') {
      if (!$hasAd) { json(['error'=>'ad_item_id_not_supported'], 422); }
      $rawList = array_filter(array_map('trim', explode(',', (string)$_GET['ad_item_ids'])), 'strlen');
      foreach ($rawList as $raw) {
        if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_ad_item_ids'], 422); }
        $adItemIds[] = (int)$raw;
      }
      $adItemIds = array_values(array_unique($adItemIds));
      if (!count($adItemIds)) { json(['error'=>'bad_ad_item_ids'], 422); }

      $in = [];
      foreach ($adItemIds as $i => $id) {
        $key = ':adid' . $i;
        $in[] = $key;
        $params[$key] = $id;
      }
      $and[] = 'ad_item_id IN (' . implode(',', $in) . ')';
    }

    if ($hasZone && isset($_GET['zone_id']) && $_GET['zone_id'] !== '') {
      $raw = trim((string)$_GET['zone_id']);
      if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_zone_id'], 422); }
      $and[] = 'zone_id = :zid';
      $params[':zid'] = (int)$raw;
    }

    $where = $and ? ('WHERE ' . implode(' AND ', $and)) : '';


  $stc = $pdo->prepare("SELECT COUNT(*) c FROM tracking_urls $where");
  foreach ($params as $k => $v) { $stc->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); }
  $stc->execute();
  $total = (int)($stc->fetch()['c'] ?? 0);

  $sort = $_GET['sort'] ?? 'created_at';
  $dir  = strtolower($_GET['dir'] ?? 'desc');
  $sort = in_array($sort, ['created_at','id'], true) ? $sort : 'created_at';
  $dir  = in_array($dir, ['asc','desc'], true) ? $dir : 'desc';
  $order = $sort.' '.strtoupper($dir).', id DESC';

  $select = "id,$destSelect,utm_source,utm_medium,utm_campaign,utm_content,utm_policy,created_at";
  if ($hasZone) $select = "id,zone_id,$destSelect,utm_source,utm_medium,utm_campaign,utm_content,utm_policy,created_at";
  if ($hasAd)   $select = $hasZone
    ? "id,zone_id,ad_item_id,$destSelect,utm_source,utm_medium,utm_campaign,utm_content,utm_policy,created_at"
    : "id,ad_item_id,$destSelect,utm_source,utm_medium,utm_campaign,utm_content,utm_policy,created_at";

  $latest = isset($_GET['latest']) && in_array(strtolower((string)$_GET['latest']), ['1','true','yes'], true);
  if ($latest && $hasAd && count($adItemIds)) {
    $destSelectLatest = $dest ? "t.$dest AS destination" : "NULL AS destination";
    $selectLatest = "t.id,$destSelectLatest,t.utm_source,t.utm_medium,t.utm_campaign,t.utm_content,t.utm_policy,t.created_at";
    if ($hasZone) $selectLatest = "t.id,t.zone_id,$destSelectLatest,t.utm_source,t.utm_medium,t.utm_campaign,t.utm_content,t.utm_policy,t.created_at";
    if ($hasAd)   $selectLatest = $hasZone
      ? "t.id,t.zone_id,t.ad_item_id,$destSelectLatest,t.utm_source,t.utm_medium,t.utm_campaign,t.utm_content,t.utm_policy,t.created_at"
      : "t.id,t.ad_item_id,$destSelectLatest,t.utm_source,t.utm_medium,t.utm_campaign,t.utm_content,t.utm_policy,t.created_at";

    $sub = "SELECT ad_item_id, MAX(created_at) AS max_created FROM tracking_urls $where GROUP BY ad_item_id";
    $st = $pdo->prepare("SELECT $selectLatest FROM tracking_urls t JOIN ($sub) latest ON latest.ad_item_id = t.ad_item_id AND latest.max_created = t.created_at ORDER BY t.ad_item_id ASC");
    foreach ($params as $k => $v) {
      $st->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
    }
    $st->execute();
    $rows = $st->fetchAll();

    json([
      'count'    => count($rows),
      'items'    => $rows,
      'limit'    => count($rows),
      'offset'   => 0,
      'has_more' => false,
      'sort'     => $sort,
      'dir'      => $dir,
      'q'        => $q
    ]);
  }

  $st = $pdo->prepare("SELECT $select FROM tracking_urls $where ORDER BY $order LIMIT :lim OFFSET :off");
  foreach ($params as $k => $v) {
    $st->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
  }
  $st->bindValue(':lim', $limit, PDO::PARAM_INT);
  $st->bindValue(':off', $offset, PDO::PARAM_INT);
  $st->execute();
  $rows = $st->fetchAll();

  json([
    'count'    => $total,
    'items'    => $rows,
    'limit'    => $limit,
    'offset'   => $offset,
    'has_more' => ($offset + count($rows) < $total),
    'sort'     => $sort,
    'dir'      => $dir,
    'q'        => $q
  ]);
}

// POST /admin/tracking
if ($uri === '/admin/tracking' && $method === 'POST') {
  $in = json_decode(file_get_contents('php://input') ?: '[]', true);

  $dest    = dest_col();
  $colsMap = tracking_columns();
  $hasZone = isset($colsMap['zone_id']);
  $hasAd   = isset($colsMap['ad_item_id']);

    // zone_id is OPTIONAL even if the column exists
    $zone_id = null;
    if ($hasZone) {
      $raw = isset($in['zone_id']) ? trim((string)$in['zone_id']) : '';
      if ($raw === '') {
        // No zone provided => leave NULL (allowed by schema)
        $zone_id = null;
      } else {
        if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_zone_id'], 422); }
        $zone_id = (int)$raw;
        // verify zone exists only when provided
        $chk = db()->prepare('SELECT id FROM partner_zones WHERE id = ? LIMIT 1');
        $chk->execute([$zone_id]);
        if (!$chk->fetch()) { json(['error'=>'zone_not_found'], 422); }
      }
    }




  // Inputs
  $destVal = trim((string)($in['destination'] ?? $in['full_url'] ?? $in['url'] ?? ''));
  if (!domain_allowed($destVal)) json(['error'=>'destination_blocked'], 400);
  $utm_source   = $in['utm_source']   ?? null;
  $utm_medium   = $in['utm_medium']   ?? null;
  $utm_campaign = $in['utm_campaign'] ?? null;
  $utm_content  = $in['utm_content']  ?? null;
  $utm_policy   = $in['utm_policy']   ?? 'append_if_missing';


    // ad_item_id optional if column exists
    $ad_item_id = null;
    if ($hasAd && array_key_exists('ad_item_id', $in)) {
      $raw = trim((string)$in['ad_item_id']);
      if ($raw !== '' && ctype_digit($raw)) {
        $ad_item_id = (int)$raw;
      }
    }

    // If we have an ad_item_id but no zone_id, try to infer zone *strictly*:
    // - only from active campaign_zones rows for that item's campaign
    // - zone and ad item must both have non-null, non-zero width/height
    // - widths & heights must match exactly
    // - there must be exactly one matching zone_id
    if ($hasZone && $zone_id === null && $hasAd && $ad_item_id !== null) {
      try {
        $pdo = db();
        $stZ = $pdo->prepare("
          SELECT DISTINCT cz.zone_id
          FROM ad_items ai
          JOIN campaign_zones cz ON cz.campaign_id = ai.campaign_id
          JOIN partner_zones pz   ON pz.id = cz.zone_id
          WHERE ai.id = ?
            AND cz.status = 'active'
            AND pz.status = 'active'
            AND ai.width IS NOT NULL
            AND ai.height IS NOT NULL
            AND pz.width IS NOT NULL
            AND pz.height IS NOT NULL
            AND ai.width  > 0
            AND ai.height > 0
            AND pz.width  > 0
            AND pz.height > 0
            AND ai.width  = pz.width
            AND ai.height = pz.height
        ");
        $stZ->execute([$ad_item_id]);
        $zoneIds = $stZ->fetchAll(PDO::FETCH_COLUMN, 0);

        if (count($zoneIds) === 1) {
          $zone_id = (int)$zoneIds[0];
        }
        // If there are zero or multiple matches, leave $zone_id as NULL.
      } catch (Throwable $_) {
        // leave $zone_id as NULL on error; insert will succeed without zone attribution
      }
    }


  // Build INSERT dynamically
  $fields = [$dest,'utm_source','utm_medium','utm_campaign','utm_content','utm_policy','created_at'];
  $marks  = [':dest',':us',':um',':ucp',':uco',':up','NOW()'];
  $params = [':dest'=>$destVal, ':us'=>$utm_source, ':um'=>$utm_medium, ':ucp'=>$utm_campaign, ':uco'=>$utm_content, ':up'=>$utm_policy];

  if ($hasZone) { array_unshift($fields,'zone_id'); array_unshift($marks, ':z'); $params[':z'] = $zone_id; }
  if ($hasAd)   { array_splice($fields, $hasZone ? 2 : 1, 0, 'ad_item_id'); array_splice($marks,  $hasZone ? 2 : 1, 0, ':ad'); $params[':ad'] = $ad_item_id; }

  $sql = "INSERT INTO tracking_urls (".implode(',', $fields).") VALUES (".implode(',', $marks).")";
  $st = db()->prepare($sql); $st->execute($params);

  $newId = (int)db()->lastInsertId();
  json(['ok'=>true, 'id'=>$newId]);
}

// PUT/DELETE /admin/tracking/{id}
if (preg_match('#^/admin/tracking/([0-9]+)$#', $uri, $m)) {
  $id = (int)$m[1];

  if ($method === 'PUT') {
    $in = json_decode(file_get_contents('php://input') ?: '[]', true);

    $dest = dest_col();
    $colsMap = tracking_columns();
    $hasZone = isset($colsMap['zone_id']);
    $hasAd   = isset($colsMap['ad_item_id']);

    $fields = ['utm_source','utm_medium','utm_campaign','utm_content','utm_policy'];
    if ($dest) { $fields[] = $dest; }
    if ($hasAd)   { array_unshift($fields, 'ad_item_id'); }
    if ($hasZone) { array_unshift($fields, 'zone_id'); }

    $sets = []; $params = [':id'=>$id];

    foreach ($fields as $f) {
      if (!array_key_exists($f, $in)) { continue; }

      if ($f === $dest) {
        if (!domain_allowed((string)$in[$f])) { json(['error'=>'destination_blocked'], 400); }
      }

      if ($f === 'ad_item_id') {
        $raw = (string)$in[$f];
        if ($raw === '') { $sets[] = 'ad_item_id = NULL'; continue; }
        if (!ctype_digit($raw)) { json(['error'=>'bad_ad_item_id'], 422); }
        $params[':ad_item_id'] = (int)$raw;
        $sets[] = 'ad_item_id = :ad_item_id';
        continue;
      }

      if ($f === 'zone_id') {
        $raw = (string)$in[$f];
        if ($raw === '') { $sets[] = 'zone_id = NULL'; continue; }
        if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_zone_id'], 422); }
        $zoneIdVal = (int)$raw;
        $chk = db()->prepare('SELECT id FROM partner_zones WHERE id = ? LIMIT 1');
        $chk->execute([$zoneIdVal]);
        if (!$chk->fetch()) { json(['error'=>'zone_not_found'], 422); }
        $params[':zone_id'] = $zoneIdVal;
        $sets[] = 'zone_id = :zone_id';
        continue;
      }

      $sets[] = "$f = :$f";
      $params[":$f"] = $in[$f];
    }

    if (!$sets) { json(['error'=>'no_fields'], 422); }
    $sql = 'UPDATE tracking_urls SET '.implode(', ', $sets).' WHERE id = :id';
    @file_put_contents(($ROOT ?? dirname(__DIR__)).'/storage/logs/ctrack_put.log', json_encode(['id'=>$id,'in'=>$in,'sets'=>$sets,'params'=>$params,'sql'=>$sql], JSON_UNESCAPED_SLASHES).PHP_EOL, FILE_APPEND);
    $st = db()->prepare($sql); $st->execute($params);
    json(['ok'=>true, 'id'=>$id]);
  }

  if ($method === 'DELETE') {
    $st = db()->prepare('DELETE FROM tracking_urls WHERE id = ?');
    $st->execute([$id]);
    json(['ok'=>true, 'id'=>$id]);
  }
}



// GET /admin/activity-logs
if ($uri === '/admin/activity-logs' && $method === 'GET') {
  try {
    $kind = $_GET['kind'] ?? 'clicks';
    if (!in_array($kind, ['clicks','impressions'], true)) { json(['error'=>'bad_kind'], 422); }

    $limit  = isset($_GET['limit'])  ? max(1, min(500, (int)$_GET['limit'])) : 100;
    $offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;

    $table = ($kind === 'clicks') ? 'click_logs' : 'impression_logs';
    
    // Check if table exists
    $pdo = db();
    $tableCheck = $pdo->query("SHOW TABLES LIKE '$table'")->fetch();
    if (!$tableCheck) {
      error_log("Activity-logs: Table $table does not exist");
      json(['error'=>'table_not_found', 'table'=>$table], 500);
    }

    $dest = dest_col();
    $destSelect = $dest ? "t.`$dest` AS tracking_destination" : "NULL AS tracking_destination";
    $cols = tracking_columns();
    $hasZone = isset($cols['zone_id']);
    $hasAd   = isset($cols['ad_item_id']);

    $params = [];
    $and = [];

    if (isset($_GET['tracking_url_id']) && $_GET['tracking_url_id'] !== '') {
      $raw = trim((string)$_GET['tracking_url_id']);
      if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_tracking_url_id'], 422); }
      $and[] = 'l.tracking_url_id = :tid';
      $params[':tid'] = (int)$raw;
    }

    if ($hasAd && isset($_GET['ad_item_id']) && $_GET['ad_item_id'] !== '') {
      $raw = trim((string)$_GET['ad_item_id']);
      if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_ad_item_id'], 422); }
      $and[] = 't.ad_item_id = :adid';
      $params[':adid'] = (int)$raw;
    }

    if ($hasZone && isset($_GET['zone_id']) && $_GET['zone_id'] !== '') {
      $raw = trim((string)$_GET['zone_id']);
      if (!ctype_digit($raw) || (int)$raw < 1) { json(['error'=>'bad_zone_id'], 422); }
      $and[] = 't.zone_id = :zid';
      $params[':zid'] = (int)$raw;
    }

    $since = isset($_GET['since']) ? trim((string)$_GET['since']) : '';
    if ($since !== '') {
      if (!preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $since)) {
        json(['error'=>'bad_since'], 422);
      }
      $and[] = 'l.ts >= :since';
      $params[':since'] = $since;
    }

    $until = isset($_GET['until']) ? trim((string)$_GET['until']) : '';
    if ($until !== '') {
      if (!preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $until)) {
        json(['error'=>'bad_until'], 422);
      }
      $and[] = 'l.ts <= :until';
      $params[':until'] = $until;
    }

    $q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
    if ($q !== '') {
      $like = '%'.$q.'%';
      $logCols = log_columns($table);
      $trackingCols = tracking_columns();
      $or = [];
      if (isset($logCols['ip']))          { $or[] = 'l.ip LIKE :q'; }
      if (isset($logCols['user_agent']))  { $or[] = 'l.user_agent LIKE :q'; }
      if (isset($logCols['referrer']))    { $or[] = 'l.referrer LIKE :q'; }
      if (isset($logCols['host']))        { $or[] = 'l.host LIKE :q'; }
      if (isset($trackingCols['slug']))         { $or[] = 't.slug LIKE :q'; }
      if (isset($trackingCols['utm_source']))  { $or[] = 't.utm_source LIKE :q'; }
      if (isset($trackingCols['utm_medium']))  { $or[] = 't.utm_medium LIKE :q'; }
      if (isset($trackingCols['utm_campaign'])) { $or[] = 't.utm_campaign LIKE :q'; }
      if (isset($trackingCols['utm_content']))  { $or[] = 't.utm_content LIKE :q'; }
      if ($dest) { $or[] = "t.`$dest` LIKE :q"; }
      if ($kind === 'clicks') {
        if (isset($logCols['dest']))         { $or[] = 'l.dest LIKE :q'; }
        if (isset($logCols['query_string'])) { $or[] = 'l.query_string LIKE :q'; }
      }
      $and[] = '(' . implode(' OR ', $or) . ')';
      $params[':q'] = $like;
    }

    $where = $and ? ('WHERE ' . implode(' AND ', $and)) : '';
    $logCols = isset($logCols) ? $logCols : log_columns($table);

    $countSql = "SELECT COUNT(*) c FROM $table l LEFT JOIN tracking_urls t ON t.id = l.tracking_url_id $where";
    $stc = $pdo->prepare($countSql);
    foreach ($params as $k => $v) { $stc->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); }
    $stc->execute();
    $total = (int)($stc->fetch()['c'] ?? 0);

    $selectBase = [];
    $selectBase[] = 'l.id';
    if (isset($logCols['tracking_url_id'])) { $selectBase[] = 'l.tracking_url_id'; }
    if (isset($logCols['ts']))              { $selectBase[] = 'l.ts'; } else { $selectBase[] = 'NULL AS ts'; }
    $selectBase[] = isset($logCols['ip']) ? 'l.ip' : 'NULL AS ip';
    $selectBase[] = isset($logCols['user_agent']) ? 'l.user_agent' : 'NULL AS user_agent';
    $selectBase[] = isset($logCols['referrer']) ? 'l.referrer' : 'NULL AS referrer';
    $selectBase[] = isset($logCols['host']) ? 'l.host' : 'NULL AS host';
    if ($kind === 'clicks') {
      $selectBase[] = isset($logCols['dest']) ? 'l.dest' : 'NULL AS dest';
      $selectBase[] = isset($logCols['query_string']) ? 'l.query_string' : 'NULL AS query_string';
    }
    $selectBase[] = isset($logCols['http_method']) ? 'l.http_method' : 'NULL AS http_method';
    
    // Guard tracking_urls columns
    $trackingCols = tracking_columns();
    $selectBase[] = isset($trackingCols['slug']) ? 't.slug' : 'NULL AS slug';
    $selectBase[] = $destSelect;
    $selectBase[] = isset($trackingCols['utm_source']) ? 't.utm_source' : 'NULL AS utm_source';
    $selectBase[] = isset($trackingCols['utm_medium']) ? 't.utm_medium' : 'NULL AS utm_medium';
    $selectBase[] = isset($trackingCols['utm_campaign']) ? 't.utm_campaign' : 'NULL AS utm_campaign';
    $selectBase[] = isset($trackingCols['utm_content']) ? 't.utm_content' : 'NULL AS utm_content';
    $selectBase[] = isset($trackingCols['utm_policy']) ? 't.utm_policy' : 'NULL AS utm_policy';
    
    $select = implode(',', $selectBase);

    if ($hasZone) { $select .= ',t.zone_id'; }
    if ($hasAd) { $select .= ',t.ad_item_id'; }

    $sql = "SELECT $select FROM $table l LEFT JOIN tracking_urls t ON t.id = l.tracking_url_id $where ORDER BY l.id DESC LIMIT :lim OFFSET :off";
    $st = $pdo->prepare($sql);
    foreach ($params as $k => $v) { $st->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); }
    $st->bindValue(':lim', $limit, PDO::PARAM_INT);
    $st->bindValue(':off', $offset, PDO::PARAM_INT);
    $st->execute();
    $rows = $st->fetchAll();

    json([
      'count'    => $total,
      'items'    => $rows,
      'limit'    => $limit,
      'offset'   => $offset,
      'has_more' => ($offset + count($rows) < $total),
      'kind'     => $kind,
      'q'        => $q,
      'since'    => $since,
      'until'    => $until,
    ]);
  } catch (Exception $e) {
    safe_error($e, 'activity_logs_error', 'Activity logs endpoint error');
  }
}



  // GET /admin/zone-active-item?zone_id={id}
  // Canonical: match newsletter zone selection logic, then resolve latest tracking URL for that item.
  // Shape: { ok:true, zone_id:int, item_id:int|null, tracking_id:int|null }
  if ($uri === '/admin/zone-active-item' && $method === 'GET') {

    // NOTE: This endpoint is called only from the authenticated admin UI
    // to build newsletter zone snippets. For now we do not enforce the
    // ADMIN_TOKEN bearer check here so the code modal can always resolve
    // the active item + tracking URL for a zone.

    $raw = isset($_GET['zone_id']) ? trim((string)$_GET['zone_id']) : '';
    if ($raw === '' || !ctype_digit($raw) || (int)$raw < 1) {
      json(['ok' => false, 'error' => 'bad_zone_id'], 422);
    }
    $zoneId = (int)$raw;

    $pdo = db();

    // 0) Load zone dimensions (if any) so we can prefer size-matching items.
    $zw = 0;
    $zh = 0;
    try {
      $stZone = $pdo->prepare('SELECT width, height FROM partner_zones WHERE id = ? LIMIT 1');
      $stZone->execute([$zoneId]);
      if ($rowZ = $stZone->fetch(PDO::FETCH_ASSOC)) {
        $zw = (int)($rowZ['width']  ?? 0);
        $zh = (int)($rowZ['height'] ?? 0);
      }
    } catch (Throwable $_) {
      $zw = 0;
      $zh = 0;
    }

    // Helper: build dimension filter + params for ad_items queries.
    $dimSql  = '';
    $dimArgs = [];
    if ($zw > 0 && $zh > 0) {
      $dimSql  = ' AND ai.width = ? AND ai.height = ?';
      $dimArgs = [$zw, $zh];
    }

    // 1) Select an image ad item tied to this zone (active campaigns / links), size-aware.
    $itemId = 0;
    try {
      $sqlActive = "
        SELECT ai.id
        FROM ad_items ai
        JOIN campaign_zones cz ON cz.campaign_id = ai.campaign_id
        JOIN campaigns      c  ON c.id = ai.campaign_id
        WHERE cz.zone_id = ?
          AND cz.status = 'active'
          AND ai.type = 'image'
          AND ai.status = 'active'
          AND (ai.start_at  IS NULL OR ai.start_at  <= NOW())
          AND (ai.end_at    IS NULL OR ai.end_at    >= NOW())
          AND c.status = 'active'
          AND (c.start_at IS NULL OR c.start_at <= NOW())
          AND (c.end_at   IS NULL OR c.end_at   >= NOW())" . $dimSql . "
        ORDER BY ai.id DESC
        LIMIT 1";
      $st = $pdo->prepare($sqlActive);
      $params = array_merge([$zoneId], $dimArgs);
      $st->execute($params);
      $itemId = (int)($st->fetchColumn() ?: 0);

      if ($itemId <= 0) {
        // Passback: any image item tied to active zone-campaigns, still honoring size when defined.
        $sqlAny = "
          SELECT ai.id
          FROM ad_items ai
          JOIN campaign_zones cz ON cz.campaign_id = ai.campaign_id
          JOIN campaigns      c  ON c.id = ai.campaign_id
          WHERE cz.zone_id = ?
            AND cz.status = 'active'
            AND ai.type = 'image'
            AND ai.status = 'active'
            AND (ai.start_at  IS NULL OR ai.start_at  <= NOW())
            AND (ai.end_at    IS NULL OR ai.end_at    >= NOW())
            AND c.status = 'active'
            AND (c.start_at IS NULL OR c.start_at <= NOW())
            AND (c.end_at   IS NULL OR c.end_at   >= NOW())" . $dimSql . "
          ORDER BY ai.id DESC
          LIMIT 1";
        $st = $pdo->prepare($sqlAny);
        $params = array_merge([$zoneId], $dimArgs);
        $st->execute($params);
        $itemId = (int)($st->fetchColumn() ?: 0);
      }
    } catch (Throwable $_) {
      $itemId = 0;
    }

    // 2) Resolve tracking URL id (tid) for the SELECTED ITEM
    $trackingId = null;
    if ($itemId > 0) {
      try {
        $st = $pdo->prepare('SELECT id FROM tracking_urls WHERE ad_item_id = ? ORDER BY id DESC LIMIT 1');
        $st->execute([$itemId]);
        $trackingId = ($row = $st->fetch(PDO::FETCH_ASSOC)) ? (int)$row['id'] : null;
      } catch (Throwable $_) {
        $trackingId = null;
      }

      // Fallback: if no tracking URL exists but the ad item has a target URL,
      // create a tracking URL so newsletter snippets work without UTM values.
      if ($trackingId === null) {
        try {
          $stT = $pdo->prepare('SELECT target FROM ad_items WHERE id = ? LIMIT 1');
          $stT->execute([$itemId]);
          $target = ($rowT = $stT->fetch(PDO::FETCH_ASSOC)) ? trim((string)($rowT['target'] ?? '')) : '';

          $destCol = dest_col();
          $colsMap = tracking_columns();
          $hasZoneCol = isset($colsMap['zone_id']);
          $hasAdCol = isset($colsMap['ad_item_id']);

          if ($target !== '' && $destCol && domain_allowed($target)) {
            $fields = [$destCol,'utm_source','utm_medium','utm_campaign','utm_content','utm_policy','created_at'];
            $marks  = [':dest',':us',':um',':ucp',':uco',':up','NOW()'];
            $params = [
              ':dest' => $target,
              ':us'   => null,
              ':um'   => null,
              ':ucp'  => null,
              ':uco'  => null,
              ':up'   => 'append_if_missing'
            ];

            if ($hasZoneCol) { array_unshift($fields,'zone_id'); array_unshift($marks, ':z');  $params[':z']  = $zoneId; }
            if ($hasAdCol)   { array_splice($fields, $hasZoneCol ? 2 : 1, 0, 'ad_item_id'); array_splice($marks, $hasZoneCol ? 2 : 1, 0, ':ad'); $params[':ad'] = $itemId; }

            $sql = 'INSERT INTO tracking_urls ('.implode(',', $fields).') VALUES ('.implode(',', $marks).')';
            $stI = $pdo->prepare($sql);
            $stI->execute($params);
            $trackingId = (int)$pdo->lastInsertId();
          }
        } catch (Throwable $_) {
          $trackingId = null;
        }
      }
    }

    json([
      'ok'          => true,
      'zone_id'     => $zoneId,
      'item_id'     => $itemId > 0 ? $itemId : null,
      'tracking_id' => $trackingId,
    ]);
  }

    // GET /admin/ad-item-active-tracking?ad_item_id={id}
    // Canonical: resolve latest tracking_urls.id for a specific Ad Item.
    // Shape: { ok:true, item_id:int, tracking_id:int|null }
    if ($uri === '/admin/ad-item-active-tracking' && $method === 'GET') {

      $raw = isset($_GET['ad_item_id']) ? trim((string)$_GET['ad_item_id']) : '';
      if ($raw === '' || !ctype_digit($raw) || (int)$raw < 1) {
        json(['ok' => false, 'error' => 'bad_ad_item_id'], 422);
      }
      $itemId = (int)$raw;

      $pdo = db();
      $trackingId = null;

      try {
        $st = $pdo->prepare('SELECT id FROM tracking_urls WHERE ad_item_id = ? ORDER BY id DESC LIMIT 1');
        $st->execute([$itemId]);
        $row = $st->fetch(PDO::FETCH_ASSOC);
        $trackingId = $row ? (int)$row['id'] : null;
      } catch (Throwable $_) {
        $trackingId = null;
      }

      json([
        'ok'          => true,
        'item_id'     => $itemId,
        'tracking_id' => $trackingId,
      ]);
    }

  // GET /admin/reports/summary
  // Returns aggregate click/impression metrics across all data or filtered by date range + dimensions
  if ($uri === '/admin/reports/summary' && $method === 'GET') {
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    $lastHours = isset($_GET['last_hours']) && $_GET['last_hours'] !== '' ? (int)$_GET['last_hours'] : 0;
    $partnerId = isset($_GET['partner_id']) && $_GET['partner_id'] ? (int)$_GET['partner_id'] : null;
    $zoneId = isset($_GET['zone_id']) && $_GET['zone_id'] ? (int)$_GET['zone_id'] : null;
    $advertiserId = isset($_GET['advertiser_id']) && $_GET['advertiser_id'] ? (int)$_GET['advertiser_id'] : null;
    $campaignId = isset($_GET['campaign_id']) && $_GET['campaign_id'] ? (int)$_GET['campaign_id'] : null;
    $adItemId = isset($_GET['ad_item_id']) && $_GET['ad_item_id'] ? (int)$_GET['ad_item_id'] : null;
    $status = isset($_GET['status']) && $_GET['status'] ? trim($_GET['status']) : null;
    
    $pdo = db();

    $normalizeBoundary = static function (?string $value, bool $isStart): ?string {
      if ($value === null || $value === '') {
        return null;
      }

      $v = trim($value);
      if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
        return $v . ($isStart ? ' 00:00:00' : ' 23:59:59');
      }
      if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?$/', $v)) {
        return str_replace('T', ' ', $v);
      }

      return null;
    };

    $sinceBound = $normalizeBoundary($since, true);
    $untilBound = $normalizeBoundary($until, false);

    if ($lastHours > 0) {
      try {
        $now = new DateTimeImmutable('now', new DateTimeZone('America/Chicago'));
      } catch (Throwable $_) {
        $now = new DateTimeImmutable('now');
      }
      $sinceBound = $now->sub(new DateInterval('PT' . $lastHours . 'H'))->format('Y-m-d H:i:s');
      $untilBound = $now->format('Y-m-d H:i:s');
    }
    
    // Enforce active entities for all Ad Reports calculations.
    $activeEntityWhere = ""
      . " AND t.zone_id IN (SELECT id FROM partner_zones WHERE status = 'active')"
      . " AND t.ad_item_id IN ("
      . "SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id = ai.campaign_id "
      . "WHERE ai.status = 'active' AND c.status = 'active'"
      . ")";

    // Build filter WHERE clause for dimension constraints
    $dimWhere = "";
    $dimParams = [];
    
    if ($partnerId || $zoneId) {
      $dimWhere .= " AND t.zone_id IN (SELECT id FROM partner_zones WHERE 1=1";
      if ($partnerId) { $dimWhere .= " AND partner_id = ?"; $dimParams[] = $partnerId; }
      if ($zoneId) { $dimWhere .= " AND id = ?"; $dimParams[] = $zoneId; }
      $dimWhere .= ")";
    }
    
    if ($advertiserId || $campaignId || $adItemId) {
      $dimWhere .= " AND t.ad_item_id IN (SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id = ai.campaign_id WHERE 1=1";
      if ($advertiserId) { $dimWhere .= " AND c.advertiser_id = ?"; $dimParams[] = $advertiserId; }
      if ($campaignId) { $dimWhere .= " AND ai.campaign_id = ?"; $dimParams[] = $campaignId; }
      if ($adItemId) { $dimWhere .= " AND ai.id = ?"; $dimParams[] = $adItemId; }
      $dimWhere .= ")";
    }
    
    // Add status filtering if specified
    if ($status) {
      // Retained for compatibility, but active-only enforcement above is authoritative.
      $dimWhere .= " AND t.ad_item_id IN (SELECT id FROM ad_items WHERE status = ?)";
      $dimParams[] = $status;
    }
    
    // Count total clicks
    $clickSql = "SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id = cl.tracking_url_id WHERE 1=1" . $activeEntityWhere . $dimWhere;
    $clickParams = array_merge($dimParams);
    if ($sinceBound) {
      $clickSql .= " AND cl.ts >= ?";
      $clickParams[] = $sinceBound;
    }
    if ($untilBound) {
      $clickSql .= " AND cl.ts <= ?";
      $clickParams[] = $untilBound;
    }
    
    $clickStmt = $pdo->prepare($clickSql);
    $clickStmt->execute($clickParams);
    $totalClicks = (int)$clickStmt->fetchColumn();
    
    // Count total impressions
    $impSql = "SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id = il.tracking_url_id WHERE 1=1" . $activeEntityWhere . $dimWhere;
    $impParams = array_merge($dimParams);
    if ($sinceBound) {
      $impSql .= " AND il.ts >= ?";
      $impParams[] = $sinceBound;
    }
    if ($untilBound) {
      $impSql .= " AND il.ts <= ?";
      $impParams[] = $untilBound;
    }
    
    $impStmt = $pdo->prepare($impSql);
    $impStmt->execute($impParams);
    $totalImpressions = (int)$impStmt->fetchColumn();
    
    $ctr = $totalImpressions > 0 ? round(($totalClicks / $totalImpressions) * 100, 2) : 0;
    
    json([
      'ok' => true,
      'totalClicks' => $totalClicks,
      'totalImpressions' => $totalImpressions,
      'ctr' => $ctr,
      'since' => $sinceBound,
      'until' => $untilBound
    ]);
  }

  // GET /admin/reports/breakdown?group_by={partner|zone|advertiser|campaign|ad_item}&since=...&until=...&filters...
  // Returns detailed breakdown by specified grouping with optional dimension filters
  if ($uri === '/admin/reports/breakdown' && $method === 'GET') {
    $groupBy = isset($_GET['group_by']) ? trim($_GET['group_by']) : 'partner';
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    $lastHours = isset($_GET['last_hours']) && $_GET['last_hours'] !== '' ? (int)$_GET['last_hours'] : 0;
    $partnerId = isset($_GET['partner_id']) && $_GET['partner_id'] ? (int)$_GET['partner_id'] : null;
    $zoneId = isset($_GET['zone_id']) && $_GET['zone_id'] ? (int)$_GET['zone_id'] : null;
    $advertiserId = isset($_GET['advertiser_id']) && $_GET['advertiser_id'] ? (int)$_GET['advertiser_id'] : null;
    $campaignId = isset($_GET['campaign_id']) && $_GET['campaign_id'] ? (int)$_GET['campaign_id'] : null;
    $adItemId = isset($_GET['ad_item_id']) && $_GET['ad_item_id'] ? (int)$_GET['ad_item_id'] : null;
    $status = isset($_GET['status']) && $_GET['status'] ? trim($_GET['status']) : null;
    
    if (!in_array($groupBy, ['partner', 'zone', 'advertiser', 'campaign', 'ad_item'])) {
      json(['error' => 'invalid_group_by'], 400);
    }
    
    $pdo = db();
    $breakdown = [];

    // Enforce active entities for all Ad Reports calculations.
    $activeEntityWhere = ""
      . " AND t.zone_id IN (SELECT id FROM partner_zones WHERE status = 'active')"
      . " AND t.ad_item_id IN ("
      . "SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id = ai.campaign_id "
      . "WHERE ai.status = 'active' AND c.status = 'active'"
      . ")";

    $normalizeBoundary = static function (?string $value, bool $isStart): ?string {
      if ($value === null || $value === '') {
        return null;
      }

      $v = trim($value);
      if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
        return $v . ($isStart ? ' 00:00:00' : ' 23:59:59');
      }
      if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?$/', $v)) {
        return str_replace('T', ' ', $v);
      }

      return null;
    };

    $sinceBound = $normalizeBoundary($since, true);
    $untilBound = $normalizeBoundary($until, false);

    if ($lastHours > 0) {
      try {
        $now = new DateTimeImmutable('now', new DateTimeZone('America/Chicago'));
      } catch (Throwable $_) {
        $now = new DateTimeImmutable('now');
      }
      $sinceBound = $now->sub(new DateInterval('PT' . $lastHours . 'H'))->format('Y-m-d H:i:s');
      $untilBound = $now->format('Y-m-d H:i:s');
    }
    
    if ($groupBy === 'partner') {
      // Group by partner
      $sql = "SELECT id, name, status FROM partners";
      $whereParts = [];
      $listParams = [];
      if ($partnerId) { $whereParts[] = "id = ?"; $listParams[] = $partnerId; }
      if ($status) { $whereParts[] = "status = ?"; $listParams[] = $status; }
      if ($whereParts) $sql .= " WHERE " . implode(" AND ", $whereParts);
      $sql .= " ORDER BY name ASC";
      
      $stmt = $pdo->prepare($sql);
      $stmt->execute($listParams);
      $partners = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($partners as $partner) {
        $dimWhere = "";
        $dimParams = [];
        
        // Partner constraint
        $dimWhere .= " AND t.zone_id IN (SELECT id FROM partner_zones WHERE partner_id = ?)";
        $dimParams[] = $partner['id'];
        
        // Additional filters
        if($zoneId){$dimWhere.=" AND t.zone_id = ?";$dimParams[]=$zoneId;}
        if($advertiserId||$campaignId||$adItemId){
          $dimWhere.=" AND t.ad_item_id IN (SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id=ai.campaign_id WHERE 1=1";
          if($advertiserId){$dimWhere.=" AND c.advertiser_id=?";$dimParams[]=$advertiserId;}
          if($campaignId){$dimWhere.=" AND ai.campaign_id=?";$dimParams[]=$campaignId;}
          if($adItemId){$dimWhere.=" AND ai.id=?";$dimParams[]=$adItemId;}
          $dimWhere.=")";
        }
        if($status){$dimWhere.=" AND t.ad_item_id IN (SELECT id FROM ad_items WHERE status=?)";$dimParams[]=$status;}
        
        $clickSql = "SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id=cl.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $clickParams = array_merge($dimParams);
        if($sinceBound){$clickSql.=" AND cl.ts>=?";$clickParams[]=$sinceBound;}
        if($untilBound){$clickSql.=" AND cl.ts<=?";$clickParams[]=$untilBound;}
        $clickStmt=$pdo->prepare($clickSql);$clickStmt->execute($clickParams);
        $clicks=(int)$clickStmt->fetchColumn();
        
        $impSql = "SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id=il.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $impParams = array_merge($dimParams);
        if($sinceBound){$impSql.=" AND il.ts>=?";$impParams[]=$sinceBound;}
        if($untilBound){$impSql.=" AND il.ts<=?";$impParams[]=$untilBound;}
        $impStmt=$pdo->prepare($impSql);$impStmt->execute($impParams);
        $impressions=(int)$impStmt->fetchColumn();
        
        $ctr = $impressions>0 ? round(($clicks/$impressions)*100,2):0;
        $breakdown[]=['id'=>$partner['id'],'name'=>$partner['name'],'status'=>$partner['status'],'clicks'=>$clicks,'impressions'=>$impressions,'ctr'=>$ctr];
      }
    } elseif ($groupBy === 'zone') {
      // Group by zone
      $sql = "SELECT z.id, z.name, z.partner_id, p.name as partner_name FROM partner_zones z JOIN partners p ON p.id=z.partner_id WHERE z.status = 'active'";
      $whereParts = [];
      $listParams = [];
      if($partnerId){$whereParts[]="z.partner_id=?";$listParams[]=$partnerId;}
      if($zoneId){$whereParts[]="z.id=?";$listParams[]=$zoneId;}
      if($status){$whereParts[]="z.status=?";$listParams[]=$status;}
      if($whereParts) $sql.=" AND ".implode(" AND ",$whereParts);
      $sql.=" ORDER BY z.name ASC";
      
      $stmt=$pdo->prepare($sql);$stmt->execute($listParams);
      $zones=$stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach($zones as $zone){
        $dimWhere="";$dimParams=[];
        $dimWhere.=" AND t.zone_id=?";$dimParams[]=$zone['id'];
        if($advertiserId||$campaignId||$adItemId){
          $dimWhere.=" AND t.ad_item_id IN (SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id=ai.campaign_id WHERE 1=1";
          if($advertiserId){$dimWhere.=" AND c.advertiser_id=?";$dimParams[]=$advertiserId;}
          if($campaignId){$dimWhere.=" AND ai.campaign_id=?";$dimParams[]=$campaignId;}
          if($adItemId){$dimWhere.=" AND ai.id=?";$dimParams[]=$adItemId;}
          $dimWhere.=")";
        }
        if($status){$dimWhere.=" AND t.ad_item_id IN (SELECT id FROM ad_items WHERE status=?)";$dimParams[]=$status;}
        
        $clickSql="SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id=cl.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $clickParams=array_merge($dimParams);
        if($sinceBound){$clickSql.=" AND cl.ts>=?";$clickParams[]=$sinceBound;}
        if($untilBound){$clickSql.=" AND cl.ts<=?";$clickParams[]=$untilBound;}
        $clickStmt=$pdo->prepare($clickSql);$clickStmt->execute($clickParams);
        $clicks=(int)$clickStmt->fetchColumn();
        
        $impSql="SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id=il.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $impParams=array_merge($dimParams);
        if($sinceBound){$impSql.=" AND il.ts>=?";$impParams[]=$sinceBound;}
        if($untilBound){$impSql.=" AND il.ts<=?";$impParams[]=$untilBound;}
        $impStmt=$pdo->prepare($impSql);$impStmt->execute($impParams);
        $impressions=(int)$impStmt->fetchColumn();
        
        $ctr=$impressions>0?round(($clicks/$impressions)*100,2):0;
        $breakdown[]=['id'=>$zone['id'],'name'=>$zone['name'],'partner'=>$zone['partner_name'],'partner_id'=>$zone['partner_id'],'clicks'=>$clicks,'impressions'=>$impressions,'ctr'=>$ctr];
      }
    } elseif ($groupBy === 'advertiser') {
      // Group by advertiser
      $sql = "SELECT a.id, a.name, a.status, COUNT(DISTINCT c.id) as campaign_count, COUNT(DISTINCT ai.id) as ad_item_count FROM advertisers a LEFT JOIN campaigns c ON c.advertiser_id=a.id LEFT JOIN ad_items ai ON ai.campaign_id=c.id";
      $whereParts=[];$listParams=[];
      if($advertiserId){$whereParts[]="a.id=?";$listParams[]=$advertiserId;}
      if($status){$whereParts[]="a.status=?";$listParams[]=$status;}
      if($whereParts)$sql.=" WHERE ".implode(" AND ",$whereParts);
      $sql.=" GROUP BY a.id, a.name, a.status ORDER BY a.name ASC";
      
      $stmt=$pdo->prepare($sql);$stmt->execute($listParams);
      $advertisers=$stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach($advertisers as $adv){
        $dimWhere="";$dimParams=[];
        $dimWhere.=" AND t.ad_item_id IN (SELECT ai.id FROM ad_items ai JOIN campaigns c ON c.id=ai.campaign_id WHERE c.advertiser_id=?)";
        $dimParams[]=$adv['id'];
        if($campaignId){$dimWhere.=" AND ai.campaign_id=?";$dimParams[]=$campaignId;}
        if($adItemId){$dimWhere.=" AND ai.id=?";$dimParams[]=$adItemId;}
        if($partnerId||$zoneId){
          $dimWhere.=" AND t.zone_id IN (SELECT id FROM partner_zones WHERE 1=1";
          if($partnerId){$dimWhere.=" AND partner_id=?";$dimParams[]=$partnerId;}
          if($zoneId){$dimWhere.=" AND id=?";$dimParams[]=$zoneId;}
          $dimWhere.=")";
        }
        
        $clickSql="SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id=cl.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $clickParams=array_merge($dimParams);
        if($sinceBound){$clickSql.=" AND cl.ts>=?";$clickParams[]=$sinceBound;}
        if($untilBound){$clickSql.=" AND cl.ts<=?";$clickParams[]=$untilBound;}
        $clickStmt=$pdo->prepare($clickSql);$clickStmt->execute($clickParams);
        $clicks=(int)$clickStmt->fetchColumn();
        
        $impSql="SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id=il.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $impParams=array_merge($dimParams);
        if($sinceBound){$impSql.=" AND il.ts>=?";$impParams[]=$sinceBound;}
        if($untilBound){$impSql.=" AND il.ts<=?";$impParams[]=$untilBound;}
        $impStmt=$pdo->prepare($impSql);$impStmt->execute($impParams);
        $impressions=(int)$impStmt->fetchColumn();
        
        $ctr=$impressions>0?round(($clicks/$impressions)*100,2):0;
        $breakdown[]=['id'=>$adv['id'],'name'=>$adv['name'],'status'=>$adv['status'],'campaigns'=>(int)$adv['campaign_count'],'adItems'=>(int)$adv['ad_item_count'],'clicks'=>$clicks,'impressions'=>$impressions,'ctr'=>$ctr];
      }
    } elseif ($groupBy === 'campaign') {
      // Group by campaign
      $sql = "SELECT c.id, c.name, c.status, c.advertiser_id, a.name as advertiser_name, COUNT(DISTINCT ai.id) as ad_item_count FROM campaigns c JOIN advertisers a ON a.id=c.advertiser_id LEFT JOIN ad_items ai ON ai.campaign_id=c.id WHERE c.status = 'active'";
      $whereParts=[];$listParams=[];
      if($advertiserId){$whereParts[]="c.advertiser_id=?";$listParams[]=$advertiserId;}
      if($campaignId){$whereParts[]="c.id=?";$listParams[]=$campaignId;}
      if($status){$whereParts[]="c.status=?";$listParams[]=$status;}
      if($whereParts)$sql.=" AND ".implode(" AND ",$whereParts);
      $sql.=" GROUP BY c.id, c.name, c.status, c.advertiser_id, a.name ORDER BY c.name ASC";
      
      $stmt=$pdo->prepare($sql);$stmt->execute($listParams);
      $campaigns=$stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach($campaigns as $camp){
        $dimWhere="";$dimParams=[];
        $dimWhere.=" AND t.ad_item_id IN (SELECT id FROM ad_items WHERE campaign_id=?)";
        $dimParams[]=$camp['id'];
        if($adItemId){$dimWhere.=" AND t.ad_item_id=?";$dimParams[]=$adItemId;}
        if($partnerId||$zoneId){
          $dimWhere.=" AND t.zone_id IN (SELECT id FROM partner_zones WHERE 1=1";
          if($partnerId){$dimWhere.=" AND partner_id=?";$dimParams[]=$partnerId;}
          if($zoneId){$dimWhere.=" AND id=?";$dimParams[]=$zoneId;}
          $dimWhere.=")";
        }
        
        $clickSql="SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id=cl.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $clickParams=array_merge($dimParams);
        if($sinceBound){$clickSql.=" AND cl.ts>=?";$clickParams[]=$sinceBound;}
        if($untilBound){$clickSql.=" AND cl.ts<=?";$clickParams[]=$untilBound;}
        $clickStmt=$pdo->prepare($clickSql);$clickStmt->execute($clickParams);
        $clicks=(int)$clickStmt->fetchColumn();
        
        $impSql="SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id=il.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $impParams=array_merge($dimParams);
        if($sinceBound){$impSql.=" AND il.ts>=?";$impParams[]=$sinceBound;}
        if($untilBound){$impSql.=" AND il.ts<=?";$impParams[]=$untilBound;}
        $impStmt=$pdo->prepare($impSql);$impStmt->execute($impParams);
        $impressions=(int)$impStmt->fetchColumn();
        
        $ctr=$impressions>0?round(($clicks/$impressions)*100,2):0;
        $breakdown[]=['id'=>$camp['id'],'name'=>$camp['name'],'status'=>$camp['status'],'advertiser'=>$camp['advertiser_name'],'advertiser_id'=>$camp['advertiser_id'],'adItems'=>(int)$camp['ad_item_count'],'clicks'=>$clicks,'impressions'=>$impressions,'ctr'=>$ctr];
      }
    } elseif ($groupBy === 'ad_item') {
      // Group by ad item
      $sql = "SELECT ai.id, ai.name, ai.status, ai.type, ai.campaign_id, c.name as campaign_name, c.advertiser_id, a.name as advertiser_name FROM ad_items ai JOIN campaigns c ON c.id=ai.campaign_id JOIN advertisers a ON a.id=c.advertiser_id WHERE ai.status = 'active' AND c.status = 'active'";
      $whereParts=[];$listParams=[];
      if($advertiserId){$whereParts[]="c.advertiser_id=?";$listParams[]=$advertiserId;}
      if($campaignId){$whereParts[]="ai.campaign_id=?";$listParams[]=$campaignId;}
      if($adItemId){$whereParts[]="ai.id=?";$listParams[]=$adItemId;}
      if($status){$whereParts[]="ai.status=?";$listParams[]=$status;}
      if($whereParts)$sql.=" AND ".implode(" AND ",$whereParts);
      $sql.=" ORDER BY ai.name ASC";
      
      $stmt=$pdo->prepare($sql);$stmt->execute($listParams);
      $adItems=$stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach($adItems as $item){
        $dimWhere="";$dimParams=[];
        $dimWhere.=" AND t.ad_item_id=?";
        $dimParams[]=$item['id'];
        if($partnerId||$zoneId){
          $dimWhere.=" AND t.zone_id IN (SELECT id FROM partner_zones WHERE 1=1";
          if($partnerId){$dimWhere.=" AND partner_id=?";$dimParams[]=$partnerId;}
          if($zoneId){$dimWhere.=" AND id=?";$dimParams[]=$zoneId;}
          $dimWhere.=")";
        }
        
        $clickSql="SELECT COUNT(*) FROM click_logs cl JOIN tracking_urls t ON t.id=cl.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $clickParams=array_merge($dimParams);
        if($sinceBound){$clickSql.=" AND cl.ts>=?";$clickParams[]=$sinceBound;}
        if($untilBound){$clickSql.=" AND cl.ts<=?";$clickParams[]=$untilBound;}
        $clickStmt=$pdo->prepare($clickSql);$clickStmt->execute($clickParams);
        $clicks=(int)$clickStmt->fetchColumn();
        
        $impSql="SELECT COUNT(*) FROM impression_logs il JOIN tracking_urls t ON t.id=il.tracking_url_id WHERE 1=1".$activeEntityWhere.$dimWhere;
        $impParams=array_merge($dimParams);
        if($sinceBound){$impSql.=" AND il.ts>=?";$impParams[]=$sinceBound;}
        if($untilBound){$impSql.=" AND il.ts<=?";$impParams[]=$untilBound;}
        $impStmt=$pdo->prepare($impSql);$impStmt->execute($impParams);
        $impressions=(int)$impStmt->fetchColumn();
        
        $ctr=$impressions>0?round(($clicks/$impressions)*100,2):0;
        $breakdown[]=['id'=>$item['id'],'name'=>$item['name'],'status'=>$item['status'],'type'=>$item['type'],'campaign'=>$item['campaign_name'],'campaign_id'=>$item['campaign_id'],'advertiser'=>$item['advertiser_name'],'advertiser_id'=>$item['advertiser_id'],'clicks'=>$clicks,'impressions'=>$impressions,'ctr'=>$ctr];
      }
    }
    
    json([
      'ok' => true,
      'groupBy' => $groupBy,
      'breakdown' => $breakdown,
      'since' => $sinceBound,
      'until' => $untilBound
    ]);
  }

  // GET /admin/search?q=...&limit=50
  // Global search across all entities
  if ($uri === '/admin/search' && $method === 'GET') {
    $auth = require_session_auth();
    
    try {
      $pdo = db();
      
      $query = isset($_GET['q']) && $_GET['q'] ? trim($_GET['q']) : '';
      $limit = isset($_GET['limit']) && $_GET['limit'] ? (int)$_GET['limit'] : 50;
      
      if (strlen($query) < 2) {
        json(['ok' => true, 'results' => []]);
        return;
      }
    
    $results = [];
    $search = "%{$query}%";
    
    // Search partners
    $stmt = $pdo->prepare("SELECT id, name FROM partners WHERE name LIKE ?");
    $stmt->execute([$search]);
    $partners = array_slice($stmt->fetchAll(PDO::FETCH_ASSOC), 0, $limit);
    foreach ($partners as $p) {
      $results[] = [
        'type' => 'partner',
        'id' => $p['id'],
        'name' => $p['name'],
        'parent' => null
      ];
    }
    
    // Search zones in partner_zones table
    $stmt = $pdo->prepare("SELECT z.id, z.name, z.partner_id, p.name as partner_name FROM partner_zones z LEFT JOIN partners p ON z.partner_id = p.id WHERE z.name LIKE ?");
    $stmt->execute([$search]);
    $zones = array_slice($stmt->fetchAll(PDO::FETCH_ASSOC), 0, $limit);
    foreach ($zones as $z) {
      $results[] = [
        'type' => 'zone',
        'id' => $z['id'],
        'name' => $z['name'],
        'parent' => $z['partner_name'],
        'partner_id' => $z['partner_id']
      ];
    }
    
    // Search advertisers
    $stmt = $pdo->prepare("SELECT id, name FROM advertisers WHERE name LIKE ?");
    $stmt->execute([$search]);
    $advertisers = array_slice($stmt->fetchAll(PDO::FETCH_ASSOC), 0, $limit);
    foreach ($advertisers as $a) {
      $results[] = [
        'type' => 'advertiser',
        'id' => $a['id'],
        'name' => $a['name'],
        'parent' => null
      ];
    }
    
    // Search campaigns
    $stmt = $pdo->prepare("SELECT c.id, c.name, c.advertiser_id, a.name as advertiser_name FROM campaigns c LEFT JOIN advertisers a ON c.advertiser_id = a.id WHERE c.name LIKE ?");
    $stmt->execute([$search]);
    $campaigns = array_slice($stmt->fetchAll(PDO::FETCH_ASSOC), 0, $limit);
    foreach ($campaigns as $c) {
      $results[] = [
        'type' => 'campaign',
        'id' => $c['id'],
        'name' => $c['name'],
        'parent' => $c['advertiser_name'],
        'advertiser_id' => $c['advertiser_id']
      ];
    }
    
    // Search ad items
    $stmt = $pdo->prepare("SELECT ai.id, ai.name, ai.campaign_id, c.name as campaign_name, c.advertiser_id, a.name as advertiser_name FROM ad_items ai LEFT JOIN campaigns c ON ai.campaign_id = c.id LEFT JOIN advertisers a ON c.advertiser_id = a.id WHERE ai.name LIKE ?");
    $stmt->execute([$search]);
    $adItems = array_slice($stmt->fetchAll(PDO::FETCH_ASSOC), 0, $limit);
    foreach ($adItems as $ai) {
      $results[] = [
        'type' => 'ad_item',
        'id' => $ai['id'],
        'name' => $ai['name'],
        'parent' => $ai['campaign_name'],
        'campaign_id' => $ai['campaign_id'],
        'advertiser_id' => $ai['advertiser_id']
      ];
    }
    
    json([
      'ok' => true,
      'results' => array_slice($results, 0, $limit)
    ]);
    } catch (Exception $e) {
      safe_error($e, 'search_error', 'Search endpoint error');
    }
    return;
  }

  // GET /admin/reports/newsletters?since=...&until=...&partner_id=...&zone_id=...&send_id=...&limit=...&offset=...&format=...
  // Newsletter analytics report for zones with type='pixel'
  // Groups by send_id (ESP campaign ID) to track each newsletter send separately
  // Returns total impressions, unique impressions, total clicks, unique clicks, and recipient emails per send
  if ($uri === '/admin/reports/newsletters' && $method === 'GET') {
    $auth = require_session_auth();

    $impressionCols = log_columns('impression_logs');
    $clickCols = log_columns('click_logs');
    $missing = [];
    if (!isset($impressionCols['send_id'])) { $missing[] = 'impression_logs.send_id'; }
    if (!isset($clickCols['send_id'])) { $missing[] = 'click_logs.send_id'; }
    if ($missing) {
      json([
        'ok' => false,
        'error' => 'newsletter_reports_schema_not_ready',
        'missing_columns' => $missing,
        'hint' => 'Apply SQL migrations including 2026-03-09-add-send-id-to-log-tables.sql and 2026-03-16-add-newsletter-send-indexes.sql'
      ], 503);
    }
    
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    $partnerId = isset($_GET['partner_id']) && $_GET['partner_id'] ? (int)$_GET['partner_id'] : null;
    $zoneId = isset($_GET['zone_id']) && $_GET['zone_id'] ? (int)$_GET['zone_id'] : null;
    $sendId = null;
    foreach (['send_id', 'cid', 'campaign_id'] as $key) {
      if (isset($_GET[$key]) && trim((string)$_GET[$key]) !== '') {
        $sendId = trim((string)$_GET[$key]);
        break;
      }
    }
    $limit = isset($_GET['limit']) && $_GET['limit'] ? (int)$_GET['limit'] : 100;
    $offset = isset($_GET['offset']) && $_GET['offset'] ? (int)$_GET['offset'] : 0;
    
    if ($limit < 1) $limit = 100;
    if ($limit > 1000) $limit = 1000;
    if ($offset < 0) $offset = 0;
    
    $pdo = db();
    
    // Build comprehensive filter for send-based grouping
    $where = ["pz.type = 'pixel'", "pz.deleted_at IS NULL", "tu.id IS NOT NULL", "il.send_id IS NOT NULL"];
    $params = [];
    
    if ($partnerId) {
      $where[] = "pz.partner_id = ?";
      $params[] = $partnerId;
    }
    
    if ($zoneId) {
      $where[] = "pz.id = ?";
      $params[] = $zoneId;
    }
    
    if ($sendId) {
      $where[] = "il.send_id = ?";
      $params[] = $sendId;
    }
    
    if ($since) {
      $where[] = "il.ts >= ?";
      $params[] = $since . ' 00:00:00';
    }
    
    if ($until) {
      $where[] = "il.ts <= ?";
      $params[] = $until . ' 23:59:59';
    }
    
    $whereClause = implode(' AND ', $where);
    
    // Get all newsletter sends grouped by (zone_id, send_id)
    // $limit and $offset are already (int) cast — safe to interpolate directly
    // (PDO binds ? as strings; MySQL rejects LIMIT '100' — must be an integer literal)
    $sql = "SELECT pz.id, pz.name, pz.partner_id, p.name as partner_name, p.status as partner_status, il.send_id,
                   COUNT(DISTINCT il.id) as total_impressions,
                   COUNT(DISTINCT il.recipient_id) as unique_impressions
            FROM partner_zones pz
            JOIN partners p ON p.id = pz.partner_id
            JOIN tracking_urls tu ON tu.zone_id = pz.id
            JOIN impression_logs il ON il.tracking_url_id = tu.id
            WHERE {$whereClause}
            GROUP BY pz.id, pz.name, pz.partner_id, p.name, p.status, il.send_id
            ORDER BY il.send_id DESC, pz.name ASC
            LIMIT {$limit} OFFSET {$offset}";
    
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $sends = $stmt->fetchAll(PDO::FETCH_ASSOC);
    
    $newsletters = [];
    
    foreach ($sends as $send) {
      $zoneId = (int)$send['id'];
      $sendIdValue = $send['send_id'];
      
      // Build WHERE clause for this specific send
      $sendWhere = ["tu.zone_id = ?", "cl.send_id = ?"];
      $sendParams = [$zoneId, $sendIdValue];
      
      if ($since) {
        $sendWhere[] = "cl.ts >= ?";
        $sendParams[] = $since . ' 00:00:00';
      }
      
      if ($until) {
        $sendWhere[] = "cl.ts <= ?";
        $sendParams[] = $until . ' 23:59:59';
      }
      
      $sendWhereClause = implode(' AND ', $sendWhere);
      
      // Get click metrics for this send
      $clickSql = "SELECT COUNT(*) as total_clicks, COUNT(DISTINCT cl.recipient_id) as unique_clicks
                   FROM click_logs cl
                   JOIN tracking_urls tu ON tu.id = cl.tracking_url_id
                   WHERE {$sendWhereClause}";
      
      $clickStmt = $pdo->prepare($clickSql);
      $clickStmt->execute($sendParams);
      $clickMetrics = $clickStmt->fetch(PDO::FETCH_ASSOC);
      
      $totalClicks = (int)($clickMetrics['total_clicks'] ?? 0);
      $uniqueClicks = (int)($clickMetrics['unique_clicks'] ?? 0);
      
      // Get recipients for this send
      $recipientWhere = ["tu.zone_id = ?", "il.send_id = ?", "il.recipient_id IS NOT NULL"];
      $recipientParams = [$zoneId, $sendIdValue];
      
      if ($since) {
        $recipientWhere[] = "il.ts >= ?";
        $recipientParams[] = $since . ' 00:00:00';
      }
      
      if ($until) {
        $recipientWhere[] = "il.ts <= ?";
        $recipientParams[] = $until . ' 23:59:59';
      }
      
      $recipientWhereClause = implode(' AND ', $recipientWhere);
      
      $recipientSql = "SELECT DISTINCT il.recipient_id
                       FROM impression_logs il
                       JOIN tracking_urls tu ON tu.id = il.tracking_url_id
                       WHERE {$recipientWhereClause}
                       ORDER BY il.recipient_id ASC
                       LIMIT 5000";
      
      $recipientStmt = $pdo->prepare($recipientSql);
      $recipientStmt->execute($recipientParams);
      $recipients = [];
      
      while ($row = $recipientStmt->fetch(PDO::FETCH_ASSOC)) {
        if ($row['recipient_id']) {
          $recipients[] = $row['recipient_id'];
        }
      }
      
      $totalImpressions = (int)$send['total_impressions'];
      $uniqueImpressions = (int)$send['unique_impressions'];
      $ctr = $totalImpressions > 0 ? round(($totalClicks / $totalImpressions) * 100, 2) : 0;
      
      $newsletters[] = [
        'zone_id' => $zoneId,
        'zone_name' => $send['name'],
        'partner_id' => (int)$send['partner_id'],
        'partner_name' => $send['partner_name'],
        'send_id' => $sendIdValue,
        'total_impressions' => $totalImpressions,
        'unique_impressions' => $uniqueImpressions,
        'total_clicks' => $totalClicks,
        'unique_clicks' => $uniqueClicks,
        'ctr' => $ctr,
        'recipients' => $recipients,
        'recipient_count' => count($recipients)
      ];
    }
    
    // Get total count for pagination
    $countSql = "SELECT COUNT(DISTINCT CONCAT(pz.id, '-', il.send_id)) as total
                 FROM partner_zones pz
                 JOIN partners p ON p.id = pz.partner_id
                 JOIN tracking_urls tu ON tu.zone_id = pz.id
                 JOIN impression_logs il ON il.tracking_url_id = tu.id
                 WHERE {$whereClause}";
    
    $countStmt = $pdo->prepare($countSql);
    $countStmt->execute($params); // $params no longer contains limit/offset (those are interpolated)
    $totalCount = (int)($countStmt->fetchColumn() ?: 0);
    
    // Handle CSV export format
    $format = strtolower(trim($_GET['format'] ?? ''));
    if ($format === 'csv') {
      header('Content-Type: text/csv; charset=utf-8');
      header('Content-Disposition: attachment; filename="newsletter-reports-' . date('Y-m-d-His') . '.csv"');
      
      $csvOut = fopen('php://output', 'w');
      
      fputcsv($csvOut, [
        'Publication',
        'Partner',
        'Send ID',
        'Total Impressions',
        'Unique Impressions',
        'Total Clicks',
        'Unique Clicks',
        'CTR',
        'Recipients'
      ]);
      
      foreach ($newsletters as $item) {
        fputcsv($csvOut, [
          $item['zone_name'] ?? '',
          $item['partner_name'] ?? '',
          $item['send_id'] ?? '',
          $item['total_impressions'] ?? 0,
          $item['unique_impressions'] ?? 0,
          $item['total_clicks'] ?? 0,
          $item['unique_clicks'] ?? 0,
          $item['ctr'] ?? 0,
          $item['recipient_count'] ?? 0
        ]);
      }
      
      fclose($csvOut);
      exit;
    }
    
    json([
      'ok' => true,
      'newsletters' => $newsletters,
      'total_count' => $totalCount,
      'limit' => $limit,
      'offset' => $offset,
      'since' => $since,
      'until' => $until,
      'partner_id' => $partnerId,
      'zone_id' => $zoneId,
      'send_id' => $sendId
    ]);
  }

  // GET /admin/reports/diagnostic?since=...&until=...&partner_id=...&advertiser_id=...&campaign_id=...&zone_id=...&group_by=...
  // Diagnostic report showing ad items, tracking URLs, and their status (Super Admin only)
  if ($uri === '/admin/reports/diagnostic' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    $partnerId = isset($_GET['partner_id']) && $_GET['partner_id'] ? (int)$_GET['partner_id'] : null;
    $advertiserId = isset($_GET['advertiser_id']) && $_GET['advertiser_id'] ? (int)$_GET['advertiser_id'] : null;
    $campaignId = isset($_GET['campaign_id']) && $_GET['campaign_id'] ? (int)$_GET['campaign_id'] : null;
    $zoneId = isset($_GET['zone_id']) && $_GET['zone_id'] ? (int)$_GET['zone_id'] : null;
    $groupBy = isset($_GET['group_by']) ? trim($_GET['group_by']) : 'ad_item';
    
    $pdo = db();
    $diagnostic = [];
    
    if ($groupBy === 'ad_item') {
      // Diagnostic by ad item
      $sql = "
        SELECT 
          ai.id,
          ai.name,
          ai.status,
          ai.type,
          ai.target,
          ai.asset_path,
          ai.asset_mime,
          ai.width,
          ai.height,
          c.id as campaign_id,
          c.name as campaign_name,
          c.status as campaign_status,
          a.id as advertiser_id,
          a.name as advertiser_name,
          a.status as advertiser_status
        FROM ad_items ai
        JOIN campaigns c ON c.id = ai.campaign_id
        JOIN advertisers a ON a.id = c.advertiser_id
        WHERE 1=1
      ";
      
      $params = [];
      if ($advertiserId) {
        $sql .= " AND a.id = ?";
        $params[] = $advertiserId;
      }
      if ($campaignId) {
        $sql .= " AND c.id = ?";
        $params[] = $campaignId;
      }
      
      $sql .= " ORDER BY a.name, c.name, ai.name";
      
      $stmt = $pdo->prepare($sql);
      $stmt->execute($params);
      $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($items as $item) {
        // Check if ad item has tracking URL
        $trackingSt = $pdo->prepare("
          SELECT 
            tu.id,
            tu.dest_url,
            tu.utm_source,
            tu.utm_medium,
            tu.utm_campaign,
            tu.utm_content,
            tu.utm_policy,
            tu.zone_id,
            tu.created_at
          FROM tracking_urls tu
          WHERE tu.ad_item_id = ?
          ORDER BY tu.created_at DESC
          LIMIT 5
        ");
        $trackingSt->execute([$item['id']]);
        $trackingUrls = $trackingSt->fetchAll(PDO::FETCH_ASSOC);
        
        // Count clicks for this ad item
        $clicksSt = $pdo->prepare("
          SELECT COUNT(*) as cnt
          FROM click_logs cl
          JOIN tracking_urls tu ON tu.id = cl.tracking_url_id
          WHERE tu.ad_item_id = ?
        ");
        if ($since) {
          $clicksSt = $pdo->prepare("
            SELECT COUNT(*) as cnt
            FROM click_logs cl
            JOIN tracking_urls tu ON tu.id = cl.tracking_url_id
            WHERE tu.ad_item_id = ?
              AND cl.ts >= ?
              AND cl.ts <= ?
          ");
          $clicksSt->execute([$item['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $clicksSt->execute([$item['id']]);
        }
        $clicks = (int)$clicksSt->fetch()['cnt'];
        
        // Count impressions for this ad item
        $impsSt = $pdo->prepare("
          SELECT COUNT(*) as cnt
          FROM impression_logs il
          JOIN tracking_urls tu ON tu.id = il.tracking_url_id
          WHERE tu.ad_item_id = ?
        ");
        if ($since) {
          $impsSt = $pdo->prepare("
            SELECT COUNT(*) as cnt
            FROM impression_logs il
            JOIN tracking_urls tu ON tu.id = il.tracking_url_id
            WHERE tu.ad_item_id = ?
              AND il.ts >= ?
              AND il.ts <= ?
          ");
          $impsSt->execute([$item['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $impsSt->execute([$item['id']]);
        }
        $impressions = (int)$impsSt->fetch()['cnt'];

        // Count identity quality issues (send_id / recipient_id missing)
        $missingImpSt = $pdo->prepare("\n          SELECT\n            SUM(CASE WHEN il.send_id IS NULL OR il.send_id = '' THEN 1 ELSE 0 END) as missing_send,\n            SUM(CASE WHEN il.recipient_id IS NULL OR il.recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n          FROM impression_logs il\n          JOIN tracking_urls tu ON tu.id = il.tracking_url_id\n          WHERE tu.ad_item_id = ?\n        ");
        if ($since) {
          $missingImpSt = $pdo->prepare("\n            SELECT\n              SUM(CASE WHEN il.send_id IS NULL OR il.send_id = '' THEN 1 ELSE 0 END) as missing_send,\n              SUM(CASE WHEN il.recipient_id IS NULL OR il.recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n            FROM impression_logs il\n            JOIN tracking_urls tu ON tu.id = il.tracking_url_id\n            WHERE tu.ad_item_id = ?\n              AND il.ts >= ?\n              AND il.ts <= ?\n          ");
          $missingImpSt->execute([$item['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $missingImpSt->execute([$item['id']]);
        }
        $missingImp = $missingImpSt->fetch(PDO::FETCH_ASSOC) ?: [];

        $missingClickSt = $pdo->prepare("\n          SELECT\n            SUM(CASE WHEN cl.send_id IS NULL OR cl.send_id = '' THEN 1 ELSE 0 END) as missing_send,\n            SUM(CASE WHEN cl.recipient_id IS NULL OR cl.recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n          FROM click_logs cl\n          JOIN tracking_urls tu ON tu.id = cl.tracking_url_id\n          WHERE tu.ad_item_id = ?\n        ");
        if ($since) {
          $missingClickSt = $pdo->prepare("\n            SELECT\n              SUM(CASE WHEN cl.send_id IS NULL OR cl.send_id = '' THEN 1 ELSE 0 END) as missing_send,\n              SUM(CASE WHEN cl.recipient_id IS NULL OR cl.recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n            FROM click_logs cl\n            JOIN tracking_urls tu ON tu.id = cl.tracking_url_id\n            WHERE tu.ad_item_id = ?\n              AND cl.ts >= ?\n              AND cl.ts <= ?\n          ");
          $missingClickSt->execute([$item['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $missingClickSt->execute([$item['id']]);
        }
        $missingClick = $missingClickSt->fetch(PDO::FETCH_ASSOC) ?: [];

        $missingSendEvents = (int)($missingImp['missing_send'] ?? 0) + (int)($missingClick['missing_send'] ?? 0);
        $missingRecipientEvents = (int)($missingImp['missing_recipient'] ?? 0) + (int)($missingClick['missing_recipient'] ?? 0);
        
        // Diagnostic flags
        $hasTrackingUrl = count($trackingUrls) > 0;
        $hasImage = !empty($item['asset_path']);
        $hasTarget = !empty($item['target']);
        $hasClicks = $clicks > 0;
        $hasImpressions = $impressions > 0;
        $hasSize = !empty($item['width']) && !empty($item['height']) && $item['width'] > 0 && $item['height'] > 0;
        
        // Calculate CTR
        $ctr = $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0;
        
        // Issues array
        $issues = [];
        if (!$hasTrackingUrl) $issues[] = 'No tracking URL';
        if (!$hasImage) $issues[] = 'No image uploaded';
        if (!$hasTarget) $issues[] = 'No target URL';
        if (!$hasSize) $issues[] = 'Missing dimensions';
        if ($item['status'] !== 'active') $issues[] = 'Not active';
        if ($item['campaign_status'] !== 'active') $issues[] = 'Campaign inactive';
        if ($item['advertiser_status'] !== 'active') $issues[] = 'Advertiser inactive';
        if ($hasTrackingUrl && !$hasClicks && !$hasImpressions) $issues[] = 'No traffic';
        if ($missingSendEvents > 0) $issues[] = 'Missing send_id: ' . $missingSendEvents;
        if ($missingRecipientEvents > 0) $issues[] = 'Missing recipient_id: ' . $missingRecipientEvents;
        
        $diagnostic[] = [
          'id' => $item['id'],
          'name' => $item['name'],
          'status' => $item['status'],
          'type' => $item['type'],
          'campaign_id' => $item['campaign_id'],
          'campaign_name' => $item['campaign_name'],
          'campaign_status' => $item['campaign_status'],
          'advertiser_id' => $item['advertiser_id'],
          'advertiser_name' => $item['advertiser_name'],
          'advertiser_status' => $item['advertiser_status'],
          'has_tracking_url' => $hasTrackingUrl,
          'tracking_url_count' => count($trackingUrls),
          'tracking_urls' => $trackingUrls,
          'has_image' => $hasImage,
          'has_target' => $hasTarget,
          'has_size' => $hasSize,
          'width' => $item['width'],
          'height' => $item['height'],
          'clicks' => $clicks,
          'impressions' => $impressions,
          'missing_send_id_events' => $missingSendEvents,
          'missing_recipient_id_events' => $missingRecipientEvents,
          'ctr' => $ctr,
          'has_clicks' => $hasClicks,
          'has_impressions' => $hasImpressions,
          'issues' => $issues,
          'issue_count' => count($issues),
          'health_score' => max(0, 100 - (count($issues) * 12))
        ];
      }
    } elseif ($groupBy === 'tracking_url') {
      // Diagnostic by tracking URL
      $sql = "
        SELECT 
          tu.id,
          tu.dest_url,
          tu.utm_source,
          tu.utm_medium,
          tu.utm_campaign,
          tu.utm_content,
          tu.utm_policy,
          tu.zone_id,
          tu.ad_item_id,
          tu.created_at,
          ai.name as ad_item_name,
          ai.status as ad_item_status,
          c.id as campaign_id,
          c.name as campaign_name,
          a.id as advertiser_id,
          a.name as advertiser_name,
          pz.id as zone_id,
          pz.name as zone_name,
          p.id as partner_id,
          p.name as partner_name
        FROM tracking_urls tu
        LEFT JOIN ad_items ai ON ai.id = tu.ad_item_id
        LEFT JOIN campaigns c ON c.id = ai.campaign_id
        LEFT JOIN advertisers a ON a.id = c.advertiser_id
        LEFT JOIN partner_zones pz ON pz.id = tu.zone_id
        LEFT JOIN partners p ON p.id = pz.partner_id
        WHERE 1=1
      ";
      
      $params = [];
      if ($advertiserId) {
        $sql .= " AND a.id = ?";
        $params[] = $advertiserId;
      }
      if ($campaignId) {
        $sql .= " AND c.id = ?";
        $params[] = $campaignId;
      }
      if ($partnerId) {
        $sql .= " AND p.id = ?";
        $params[] = $partnerId;
      }
      if ($zoneId) {
        $sql .= " AND pz.id = ?";
        $params[] = $zoneId;
      }
      
      $sql .= " ORDER BY tu.created_at DESC LIMIT 500";
      
      $stmt = $pdo->prepare($sql);
      $stmt->execute($params);
      $urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($urls as $url) {
        // Count clicks
        $clicksSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM click_logs WHERE tracking_url_id = ?");
        if ($since) {
          $clicksSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM click_logs WHERE tracking_url_id = ? AND ts >= ? AND ts <= ?");
          $clicksSt->execute([$url['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $clicksSt->execute([$url['id']]);
        }
        $clicks = (int)$clicksSt->fetch()['cnt'];
        
        // Count impressions
        $impsSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM impression_logs WHERE tracking_url_id = ?");
        if ($since) {
          $impsSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM impression_logs WHERE tracking_url_id = ? AND ts >= ? AND ts <= ?");
          $impsSt->execute([$url['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $impsSt->execute([$url['id']]);
        }
        $impressions = (int)$impsSt->fetch()['cnt'];

        // Count identity quality issues (send_id / recipient_id missing)
        $missingImpSt = $pdo->prepare("\n          SELECT\n            SUM(CASE WHEN send_id IS NULL OR send_id = '' THEN 1 ELSE 0 END) as missing_send,\n            SUM(CASE WHEN recipient_id IS NULL OR recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n          FROM impression_logs\n          WHERE tracking_url_id = ?\n        ");
        if ($since) {
          $missingImpSt = $pdo->prepare("\n            SELECT\n              SUM(CASE WHEN send_id IS NULL OR send_id = '' THEN 1 ELSE 0 END) as missing_send,\n              SUM(CASE WHEN recipient_id IS NULL OR recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n            FROM impression_logs\n            WHERE tracking_url_id = ?\n              AND ts >= ?\n              AND ts <= ?\n          ");
          $missingImpSt->execute([$url['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $missingImpSt->execute([$url['id']]);
        }
        $missingImp = $missingImpSt->fetch(PDO::FETCH_ASSOC) ?: [];

        $missingClickSt = $pdo->prepare("\n          SELECT\n            SUM(CASE WHEN send_id IS NULL OR send_id = '' THEN 1 ELSE 0 END) as missing_send,\n            SUM(CASE WHEN recipient_id IS NULL OR recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n          FROM click_logs\n          WHERE tracking_url_id = ?\n        ");
        if ($since) {
          $missingClickSt = $pdo->prepare("\n            SELECT\n              SUM(CASE WHEN send_id IS NULL OR send_id = '' THEN 1 ELSE 0 END) as missing_send,\n              SUM(CASE WHEN recipient_id IS NULL OR recipient_id = '' THEN 1 ELSE 0 END) as missing_recipient\n            FROM click_logs\n            WHERE tracking_url_id = ?\n              AND ts >= ?\n              AND ts <= ?\n          ");
          $missingClickSt->execute([$url['id'], $since . ' 00:00:00', ($until ?: date('Y-m-d')) . ' 23:59:59']);
        } else {
          $missingClickSt->execute([$url['id']]);
        }
        $missingClick = $missingClickSt->fetch(PDO::FETCH_ASSOC) ?: [];

        $missingSendEvents = (int)($missingImp['missing_send'] ?? 0) + (int)($missingClick['missing_send'] ?? 0);
        $missingRecipientEvents = (int)($missingImp['missing_recipient'] ?? 0) + (int)($missingClick['missing_recipient'] ?? 0);
        
        // Calculate CTR
        $ctr = $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0;
        
        // Diagnostic flags
        $hasDestUrl = !empty($url['dest_url']);
        $hasUtmParams = !empty($url['utm_source']) || !empty($url['utm_medium']) || !empty($url['utm_campaign']);
        $hasAdItem = !empty($url['ad_item_id']);
        $hasZone = !empty($url['zone_id']);
        $hasClicks = $clicks > 0;
        $hasImpressions = $impressions > 0;
        
        // Issues array
        $issues = [];
        if (!$hasDestUrl) $issues[] = 'No destination URL';
        if (!$hasAdItem && !$hasZone) $issues[] = 'Not linked to ad item or zone';
        if (!$hasClicks && !$hasImpressions) $issues[] = 'No traffic';
        if ($missingSendEvents > 0) $issues[] = 'Missing send_id: ' . $missingSendEvents;
        if ($missingRecipientEvents > 0) $issues[] = 'Missing recipient_id: ' . $missingRecipientEvents;
        
        $diagnostic[] = [
          'id' => $url['id'],
          'dest_url' => $url['dest_url'],
          'utm_source' => $url['utm_source'],
          'utm_medium' => $url['utm_medium'],
          'utm_campaign' => $url['utm_campaign'],
          'utm_content' => $url['utm_content'],
          'utm_policy' => $url['utm_policy'],
          'has_utm_params' => $hasUtmParams,
          'ad_item_id' => $url['ad_item_id'],
          'ad_item_name' => $url['ad_item_name'],
          'ad_item_status' => $url['ad_item_status'],
          'campaign_id' => $url['campaign_id'],
          'campaign_name' => $url['campaign_name'],
          'advertiser_id' => $url['advertiser_id'],
          'advertiser_name' => $url['advertiser_name'],
          'zone_id' => $url['zone_id'],
          'zone_name' => $url['zone_name'],
          'partner_id' => $url['partner_id'],
          'partner_name' => $url['partner_name'],
          'created_at' => $url['created_at'],
          'clicks' => $clicks,
          'impressions' => $impressions,
          'missing_send_id_events' => $missingSendEvents,
          'missing_recipient_id_events' => $missingRecipientEvents,
          'ctr' => $ctr,
          'has_clicks' => $hasClicks,
          'has_impressions' => $hasImpressions,
          'issues' => $issues,
          'issue_count' => count($issues)
        ];
      }
    }
    
    json([
      'ok' => true,
      'groupBy' => $groupBy,
      'diagnostic' => $diagnostic,
      'since' => $since,
      'until' => $until,
      'count' => count($diagnostic)
    ]);
  }

  // GET /admin/bot-defense/stats
  // Get bot defense statistics (24h summary)
  if ($uri === '/admin/bot-defense/stats' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    // Get counts for last 24 hours
    $since = date('Y-m-d H:i:s', strtotime('-24 hours'));
    
    // Blocked requests (score >= 70)
    $blockedSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? AND score >= 70");
    $blockedSt->execute([$since]);
    $blocked24h = (int)$blockedSt->fetch()['cnt'];
    
    // Suspicious requests (score 40-69)
    $suspiciousSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? AND score >= 40 AND score < 70");
    $suspiciousSt->execute([$since]);
    $suspicious24h = (int)$suspiciousSt->fetch()['cnt'];
    
    // Allowed requests: count impressions + clicks that passed bot defense checks
    $impressionsSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM impression_logs WHERE ts >= ?");
    $impressionsSt->execute([$since]);
    $impressions24h = (int)$impressionsSt->fetch()['cnt'];
    
    $clicksSt = $pdo->prepare("SELECT COUNT(*) as cnt FROM click_logs WHERE ts >= ?");
    $clicksSt->execute([$since]);
    $clicks24h = (int)$clicksSt->fetch()['cnt'];
    
    $allowed24h = $impressions24h + $clicks24h;
    
    // Calculate block rate: blocked / (blocked + allowed)
    $totalRequests = $blocked24h + $suspicious24h + $allowed24h;
    $blockRate = $totalRequests > 0 ? round(($blocked24h / $totalRequests) * 100, 2) : 0;
    
    // Top threat (most common reason)
    $topThreatSt = $pdo->prepare("SELECT reason, COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? AND score >= 70 GROUP BY reason ORDER BY cnt DESC LIMIT 1");
    $topThreatSt->execute([$since]);
    $topThreatRow = $topThreatSt->fetch();
    $topThreat = $topThreatRow ? ['reason' => $topThreatRow['reason'], 'count' => (int)$topThreatRow['cnt']] : null;
    
    // Get breakdown by reason
    $reasonsSt = $pdo->prepare("SELECT reason, COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? GROUP BY reason ORDER BY cnt DESC LIMIT 10");
    $reasonsSt->execute([$since]);
    $reasons = [];
    while ($row = $reasonsSt->fetch()) {
      $reasons[] = ['reason' => $row['reason'], 'count' => (int)$row['cnt']];
    }
    
    // Get breakdown by type
    $typesSt = $pdo->prepare("SELECT type, COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? GROUP BY type ORDER BY cnt DESC");
    $typesSt->execute([$since]);
    $types = [];
    while ($row = $typesSt->fetch()) {
      $types[] = ['type' => $row['type'], 'count' => (int)$row['cnt']];
    }
    
    // Get timeline (hourly for last 7 days)
    $timelineSince = date('Y-m-d H:i:s', strtotime('-7 days'));
    $timelineSt = $pdo->prepare("SELECT DATE_FORMAT(ts, '%Y-%m-%d %H:00:00') as hour, COUNT(*) as cnt FROM blocked_requests WHERE ts >= ? GROUP BY hour ORDER BY hour ASC");
    $timelineSt->execute([$timelineSince]);
    $timeline = [];
    while ($row = $timelineSt->fetch()) {
      $timeline[] = ['hour' => $row['hour'], 'count' => (int)$row['cnt']];
    }
    
    json([
      'ok' => true,
      'blocked24h' => $blocked24h,
      'suspicious24h' => $suspicious24h,
      'impressions24h' => $impressions24h,
      'clicks24h' => $clicks24h,
      'allowed24h' => $allowed24h,
      'blockRate' => $blockRate,
      'topThreat' => $topThreat,
      'reasons' => $reasons,
      'types' => $types,
      'timeline' => $timeline
    ]);
  }

  // GET /admin/bot-defense/blocked?type=...&reason=...&ip=...&limit=...&offset=...
  // Get list of blocked requests with filters
  if ($uri === '/admin/bot-defense/blocked' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    
    try {
      $type = isset($_GET['type']) && $_GET['type'] ? trim($_GET['type']) : null;
      $reason = isset($_GET['reason']) && $_GET['reason'] ? trim($_GET['reason']) : null;
      $ip = isset($_GET['ip']) && $_GET['ip'] ? trim($_GET['ip']) : null;
      $limit = isset($_GET['limit']) && is_numeric($_GET['limit']) ? (int)$_GET['limit'] : 50;
      $offset = isset($_GET['offset']) && is_numeric($_GET['offset']) ? (int)$_GET['offset'] : 0;
      
      $pdo = db();
      $hasTrackingUrlId = false;
      $hasZoneId = false;
      try {
        $colSt = $pdo->prepare("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blocked_requests' AND COLUMN_NAME IN ('tracking_url_id','zone_id')");
        $colSt->execute();
        while ($row = $colSt->fetch()) {
          if ($row['COLUMN_NAME'] === 'tracking_url_id') { $hasTrackingUrlId = true; }
          if ($row['COLUMN_NAME'] === 'zone_id') { $hasZoneId = true; }
        }
      } catch (Throwable $_) {
        $hasTrackingUrlId = false;
        $hasZoneId = false;
      }
      
      // Build query
      if ($hasTrackingUrlId) {
        $zoneJoinExpr = $hasZoneId ? "COALESCE(br.zone_id, tu.zone_id)" : "tu.zone_id";
        $sql = "SELECT br.*, 
                  tu.zone_id AS tracking_zone_id,
                  tu.ad_item_id AS tracking_ad_item_id,
                  pz.name AS zone_name,
                  ai.name AS ad_item_name,
                  pz.partner_id AS partner_id_from_zone
                FROM blocked_requests br
                LEFT JOIN tracking_urls tu ON tu.id = br.tracking_url_id
                LEFT JOIN partner_zones pz ON pz.id = {$zoneJoinExpr}
                LEFT JOIN ad_items ai ON ai.id = tu.ad_item_id
                WHERE 1=1";
      } elseif ($hasZoneId) {
        $sql = "SELECT br.*, 
                  NULL AS tracking_zone_id,
                  NULL AS tracking_ad_item_id,
                  pz.name AS zone_name,
                  NULL AS ad_item_name,
                  pz.partner_id AS partner_id_from_zone
                FROM blocked_requests br
                LEFT JOIN partner_zones pz ON pz.id = br.zone_id
                WHERE 1=1";
      } else {
        $sql = "SELECT br.*, 
                  NULL AS tracking_zone_id,
                  NULL AS tracking_ad_item_id,
                  NULL AS zone_name,
                  NULL AS ad_item_name,
                  NULL AS partner_id_from_zone
                FROM blocked_requests br
                WHERE 1=1";
      }
      $params = [];
      
      if ($type) {
        $sql .= " AND br.type = ?";
        $params[] = $type;
      }
      if ($reason) {
        $sql .= " AND br.reason LIKE ?";
        $params[] = "%$reason%";
      }
      if ($ip) {
        $sql .= " AND br.ip LIKE ?";
        $params[] = "%$ip%";
      }
      
      // Get total count
      $countSql = "SELECT COUNT(*) as cnt FROM blocked_requests WHERE 1=1";
      $countParams = [];
      if ($type) {
        $countSql .= " AND type = ?";
        $countParams[] = $type;
      }
      if ($reason) {
        $countSql .= " AND reason LIKE ?";
        $countParams[] = "%$reason%";
      }
      if ($ip) {
        $countSql .= " AND ip LIKE ?";
        $countParams[] = "%$ip%";
      }
      $countSt = $pdo->prepare($countSql);
      $countSt->execute($countParams);
      $total = (int)$countSt->fetch()['cnt'];
      
      // Get paginated results (use direct interpolation for LIMIT/OFFSET since they're validated integers)
      $sql .= " ORDER BY br.ts DESC LIMIT $limit OFFSET $offset";
      
      $st = $pdo->prepare($sql);
      $st->execute($params);
      
      $blocked = [];
      while ($row = $st->fetch()) {
        $zoneId = $row['zone_id'] ?? $row['tracking_zone_id'] ?? null;
        $partnerId = $row['partner_id'] ?? $row['partner_id_from_zone'] ?? null;
        $blocked[] = [
          'id' => (int)$row['id'],
          'type' => $row['type'],
          'ip' => $row['ip'],
          'user_agent' => $row['user_agent'],
          'url' => $row['url'],
          'reason' => $row['reason'],
          'score' => (int)$row['score'],
          'partner_id' => $partnerId ? (int)$partnerId : null,
          'zone_id' => $zoneId ? (int)$zoneId : null,
          'zone_name' => $row['zone_name'] ?? null,
          'ad_item_id' => $row['tracking_ad_item_id'] ? (int)$row['tracking_ad_item_id'] : null,
          'ad_item_name' => $row['ad_item_name'] ?? null,
          'advertiser_id' => $row['advertiser_id'] ? (int)$row['advertiser_id'] : null,
          'campaign_id' => $row['campaign_id'] ? (int)$row['campaign_id'] : null,
          'ts' => $row['ts']
        ];
      }
      
      json([
        'ok' => true,
        'blocked' => $blocked,
        'total' => $total,
        'limit' => $limit,
        'offset' => $offset
      ]);
    } catch (Exception $e) {
      safe_error($e, 'bot_defense_error', 'Bot Defense blocked list error');
    }
  }

  // GET /admin/bot-defense/zone-ad-report?zone=...&ad_item=...&sort=...&limit=...&offset=...
  // Get aggregated blocked totals by Zone/Ad Item
  if ($uri === '/admin/bot-defense/zone-ad-report' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);

    try {
      $zoneFilter = isset($_GET['zone']) && $_GET['zone'] ? trim($_GET['zone']) : null;
      $adItemFilter = isset($_GET['ad_item']) && $_GET['ad_item'] ? trim($_GET['ad_item']) : null;
      $sort = isset($_GET['sort']) && $_GET['sort'] ? trim($_GET['sort']) : 'blocks_desc';
      $limit = isset($_GET['limit']) && is_numeric($_GET['limit']) ? (int)$_GET['limit'] : 50;
      $offset = isset($_GET['offset']) && is_numeric($_GET['offset']) ? (int)$_GET['offset'] : 0;

      if ($limit < 1) { $limit = 50; }
      if ($limit > 200) { $limit = 200; }
      if ($offset < 0) { $offset = 0; }

      $sortMap = [
        'blocks_desc' => "blocks DESC, COALESCE(zone_name, '') ASC, COALESCE(ad_item_name, '') ASC",
        'blocks_asc' => "blocks ASC, COALESCE(zone_name, '') ASC, COALESCE(ad_item_name, '') ASC",
        'zone_asc' => "COALESCE(zone_name, '') ASC, blocks DESC",
        'zone_desc' => "COALESCE(zone_name, '') DESC, blocks DESC",
        'ad_item_asc' => "COALESCE(ad_item_name, '') ASC, blocks DESC",
        'ad_item_desc' => "COALESCE(ad_item_name, '') DESC, blocks DESC",
      ];
      $orderBy = $sortMap[$sort] ?? $sortMap['blocks_desc'];

      $pdo = db();
      $hasTrackingUrlId = false;
      $hasZoneId = false;
      try {
        $colSt = $pdo->prepare("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blocked_requests' AND COLUMN_NAME IN ('tracking_url_id','zone_id')");
        $colSt->execute();
        while ($row = $colSt->fetch()) {
          if ($row['COLUMN_NAME'] === 'tracking_url_id') { $hasTrackingUrlId = true; }
          if ($row['COLUMN_NAME'] === 'zone_id') { $hasZoneId = true; }
        }
      } catch (Throwable $_) {
        $hasTrackingUrlId = false;
        $hasZoneId = false;
      }

      if (!$hasTrackingUrlId && !$hasZoneId) {
        json([
          'ok' => true,
          'report' => [],
          'total' => 0,
          'limit' => $limit,
          'offset' => $offset
        ]);
      }

      $params = [];

      if ($hasTrackingUrlId) {
        $zoneExpr = $hasZoneId ? 'COALESCE(br.zone_id, tu.zone_id)' : 'tu.zone_id';
        $selectSql = "SELECT {$zoneExpr} AS zone_id, tu.ad_item_id AS ad_item_id, pz.name AS zone_name, ai.name AS ad_item_name, COUNT(*) AS blocks";
        $fromSql = "
          FROM blocked_requests br
          LEFT JOIN tracking_urls tu ON tu.id = br.tracking_url_id
          LEFT JOIN partner_zones pz ON pz.id = {$zoneExpr}
          LEFT JOIN ad_items ai ON ai.id = tu.ad_item_id
          WHERE br.score >= 70
        ";

        if ($zoneFilter) {
          $fromSql .= " AND (pz.name LIKE ? OR CAST({$zoneExpr} AS CHAR) LIKE ?)";
          $params[] = "%{$zoneFilter}%";
          $params[] = "%{$zoneFilter}%";
        }
        if ($adItemFilter) {
          $fromSql .= " AND (ai.name LIKE ? OR CAST(tu.ad_item_id AS CHAR) LIKE ?)";
          $params[] = "%{$adItemFilter}%";
          $params[] = "%{$adItemFilter}%";
        }

        $groupSql = " GROUP BY {$zoneExpr}, tu.ad_item_id, pz.name, ai.name";
      } else {
        $selectSql = "SELECT br.zone_id AS zone_id, NULL AS ad_item_id, pz.name AS zone_name, NULL AS ad_item_name, COUNT(*) AS blocks";
        $fromSql = "
          FROM blocked_requests br
          LEFT JOIN partner_zones pz ON pz.id = br.zone_id
          WHERE br.score >= 70
        ";

        if ($zoneFilter) {
          $fromSql .= " AND (pz.name LIKE ? OR CAST(br.zone_id AS CHAR) LIKE ?)";
          $params[] = "%{$zoneFilter}%";
          $params[] = "%{$zoneFilter}%";
        }
        if ($adItemFilter) {
          $fromSql .= " AND 1=0";
        }

        $groupSql = " GROUP BY br.zone_id, pz.name";
      }

      $reportBaseSql = $selectSql . $fromSql . $groupSql;

      $countSql = "SELECT COUNT(*) AS cnt FROM ({$reportBaseSql}) AS report_groups";
      $countSt = $pdo->prepare($countSql);
      $countSt->execute($params);
      $total = (int)$countSt->fetch()['cnt'];

      $dataSql = $reportBaseSql . " ORDER BY {$orderBy} LIMIT {$limit} OFFSET {$offset}";
      $dataSt = $pdo->prepare($dataSql);
      $dataSt->execute($params);

      $report = [];
      while ($row = $dataSt->fetch()) {
        $report[] = [
          'zone_id' => $row['zone_id'] !== null ? (int)$row['zone_id'] : null,
          'zone_name' => $row['zone_name'] ?? null,
          'ad_item_id' => $row['ad_item_id'] !== null ? (int)$row['ad_item_id'] : null,
          'ad_item_name' => $row['ad_item_name'] ?? null,
          'blocks' => (int)$row['blocks']
        ];
      }

      json([
        'ok' => true,
        'report' => $report,
        'total' => $total,
        'limit' => $limit,
        'offset' => $offset
      ]);
    } catch (Exception $e) {
      safe_error($e, 'bot_defense_error', 'Bot Defense zone/ad report error');
    }
  }

  // GET /admin/bot-defense/patterns
  // Get top IPs and User Agents
  if ($uri === '/admin/bot-defense/patterns' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    // Top IPs (true last 7 days, blocked only)
    $ipSt = $pdo->prepare("SELECT ip, COUNT(*) as cnt FROM blocked_requests WHERE ts >= (NOW() - INTERVAL 7 DAY) AND score >= 70 AND ip IS NOT NULL AND ip != '' GROUP BY ip ORDER BY cnt DESC LIMIT 20");
    $ipSt->execute();
    $topIps = [];
    while ($row = $ipSt->fetch()) {
      $topIps[] = ['ip' => $row['ip'], 'count' => (int)$row['cnt']];
    }
    
    // Top User Agents (true last 7 days, blocked only)
    $uaSt = $pdo->prepare("SELECT user_agent, COUNT(*) as cnt FROM blocked_requests WHERE ts >= (NOW() - INTERVAL 7 DAY) AND score >= 70 AND user_agent IS NOT NULL AND user_agent != '' GROUP BY user_agent ORDER BY cnt DESC LIMIT 20");
    $uaSt->execute();
    $topUserAgents = [];
    while ($row = $uaSt->fetch()) {
      $topUserAgents[] = ['user_agent' => $row['user_agent'], 'count' => (int)$row['cnt']];
    }
    
    json([
      'ok' => true,
      'topIps' => $topIps,
      'topUserAgents' => $topUserAgents
    ]);
  }

  // GET /admin/bot-defense/config
  // Get current bot detection configuration (default or partner/zone specific)
  if ($uri === '/admin/bot-defense/config' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    // Note: bot_config and bot_config_custom tables created via sql/2026-02-add-bot-defense-tables.sql migration
    // Do not use CREATE TABLE here - ct_app_rw user (H-05) does not have CREATE privileges
    
    $partnerParsed = parse_optional_positive_int($_GET['partner_id'] ?? null);
    $zoneParsed = parse_optional_positive_int($_GET['zone_id'] ?? null);
    if ($partnerParsed === false || $zoneParsed === false) {
      json(['ok' => false, 'error' => 'invalid_scope_ids'], 422);
    }
    $partnerId = $partnerParsed;
    $zoneId = $zoneParsed;

    $scope = resolve_bot_config_scope($pdo, $partnerId, $zoneId);
    if (!$scope['ok']) {
      json(['ok' => false, 'error' => $scope['error']], $scope['status']);
    }
    $partnerId = $scope['partner_id'];
    $zoneId = $scope['zone_id'];
    
    // Default config
    $config = [
      'block_bots' => true,
      'block_datacenters' => false,
      'max_clicks_per_hour' => 100,
      'max_impressions_per_hour' => 500,
      'min_click_interval' => 2,
      'min_impression_interval' => 1
    ];
    
    // Load default config
    $configSt = $pdo->query("SELECT config_key, config_value FROM bot_config");
    if ($configSt) {
      while ($row = $configSt->fetch()) {
        $key = $row['config_key'];
        $value = $row['config_value'];
        
        // Convert to appropriate type
        if ($key === 'block_bots' || $key === 'block_datacenters') {
          $config[$key] = $value === '1' || $value === 'true';
        } else {
          $config[$key] = (int)$value;
        }
      }
    }
    
    // Override with partner/zone specific config if requested
    $isCustom = false;
    if ($partnerId || $zoneId) {
      $customSt = $pdo->prepare("
        SELECT config_key, config_value 
        FROM bot_config_custom 
        WHERE partner_id <=> ? AND zone_id <=> ?
      ");
      $customSt->execute([$partnerId, $zoneId]);
      
      while ($row = $customSt->fetch()) {
        $isCustom = true;
        $key = $row['config_key'];
        $value = $row['config_value'];
        
        // Convert to appropriate type
        if ($key === 'block_bots' || $key === 'block_datacenters') {
          $config[$key] = $value === '1' || $value === 'true';
        } else {
          $config[$key] = (int)$value;
        }
      }
    }
    
    json([
      'ok' => true,
      'config' => $config,
      'is_custom' => $isCustom,
      'partner_id' => $partnerId,
      'zone_id' => $zoneId
    ]);
  }

  // POST /admin/bot-defense/config
  // Save bot detection configuration (default or partner/zone specific)
  if ($uri === '/admin/bot-defense/config' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    $json = json_decode(file_get_contents('php://input'), true);
    
    if (!$json) {
      json(['ok' => false, 'error' => 'Invalid JSON'], 400);
      exit;
    }
    
    $pdo = db();

    // Note: bot_config and bot_config_custom tables created via sql/2026-02-add-bot-defense-tables.sql migration
    // Do not use CREATE TABLE here - ct_app_rw user (H-05) does not have CREATE privileges
    
    // Accept partner_id and zone_id from either JSON body or query params
    $partnerCandidate = $json['partner_id'] ?? ($_GET['partner_id'] ?? null);
    $zoneCandidate = $json['zone_id'] ?? ($_GET['zone_id'] ?? null);

    $partnerParsed = parse_optional_positive_int($partnerCandidate);
    $zoneParsed = parse_optional_positive_int($zoneCandidate);
    if ($partnerParsed === false || $zoneParsed === false) {
      json(['ok' => false, 'error' => 'invalid_scope_ids'], 422);
    }

    $partnerId = $partnerParsed;
    $zoneId = $zoneParsed;

    $scope = resolve_bot_config_scope($pdo, $partnerId, $zoneId);
    if (!$scope['ok']) {
      json(['ok' => false, 'error' => $scope['error']], $scope['status']);
    }
    $partnerId = $scope['partner_id'];
    $zoneId = $scope['zone_id'];

    $logPath = __DIR__ . '/../storage/logs/bot-defense.log';
    $logPrefix = '[' . date('Y-m-d H:i:s') . '] ';
    $logScope = 'partner_id=' . var_export($partnerId, true) . ' zone_id=' . var_export($zoneId, true);
    @file_put_contents($logPath, $logPrefix . 'save-config start ' . $logScope . PHP_EOL, FILE_APPEND);
    
    $configKeys = ['block_bots', 'block_datacenters', 'max_clicks_per_hour', 'max_impressions_per_hour', 'min_click_interval', 'min_impression_interval'];
    
    // Save to custom table if partner/zone specified
    if ($partnerId || $zoneId) {
      try {
        $upsertSt = $pdo->prepare("
          INSERT INTO bot_config_custom (partner_id, zone_id, config_key, config_value) 
          VALUES (?, ?, ?, ?) 
          ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
        ");

        $rowsWritten = 0;
        
        foreach ($configKeys as $key) {
          if (isset($json[$key])) {
            $value = $json[$key];
            
            // Convert booleans to string
            if (is_bool($value)) {
              $value = $value ? '1' : '0';
            }
            
            $upsertSt->execute([$partnerId, $zoneId, $key, $value]);
            $rowsWritten += $upsertSt->rowCount();
          }
        }

        @file_put_contents($logPath, $logPrefix . 'save-config custom rows=' . $rowsWritten . ' ' . $logScope . PHP_EOL, FILE_APPEND);
        
        json([
          'ok' => true,
          'message' => 'Custom configuration saved successfully',
          'partner_id' => $partnerId,
          'zone_id' => $zoneId
        ]);
      } catch (PDOException $e) {
        error_log("Bot config custom save failed: " . $e->getMessage());
        @file_put_contents($logPath, $logPrefix . 'save-config error ' . $logScope . ' ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
        json([
          'ok' => false,
          'error' => 'config_save_failed'
        ], 500);
      }
    } else {
      // Save to default config table
      $upsertSt = $pdo->prepare("
        INSERT INTO bot_config (config_key, config_value) 
        VALUES (?, ?) 
        ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
      ");
      
      foreach ($configKeys as $key) {
        if (isset($json[$key])) {
          $value = $json[$key];
          
          // Convert booleans to string
          if (is_bool($value)) {
            $value = $value ? '1' : '0';
          }
          
          $upsertSt->execute([$key, $value]);
        }
      }

      @file_put_contents($logPath, $logPrefix . 'save-config default ' . $logScope . PHP_EOL, FILE_APPEND);
      
      json([
        'ok' => true,
        'message' => 'Default configuration saved successfully'
      ]);
    }
  }

  // GET /admin/bot-defense/config/custom-list
  // Get list of partners/zones with custom bot config
  if ($uri === '/admin/bot-defense/config/custom-list' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    // Note: bot_config_custom table created via sql/2026-02-add-bot-defense-tables.sql migration
    // Do not use CREATE TABLE here - ct_app_rw user (H-05) does not have CREATE privileges
    
    // Get unique partner/zone combinations with custom configs
    $sql = "
      SELECT DISTINCT 
        bc.partner_id, 
        bc.zone_id,
        p.name AS partner_name,
        pz.name AS zone_name
      FROM bot_config_custom bc
      LEFT JOIN partners p ON bc.partner_id = p.id
      LEFT JOIN partner_zones pz ON bc.zone_id = pz.id
      WHERE (bc.partner_id IS NULL OR p.id IS NOT NULL)
        AND (bc.zone_id IS NULL OR pz.id IS NOT NULL)
        AND (bc.zone_id IS NULL OR bc.partner_id = pz.partner_id)
      ORDER BY p.name, pz.name
    ";
    
    $st = $pdo->query($sql);
    $configs = [];
    
    if ($st) {
      while ($row = $st->fetch()) {
        $configs[] = [
          'partner_id' => $row['partner_id'],
          'zone_id' => $row['zone_id'],
          'partner_name' => $row['partner_name'],
          'zone_name' => $row['zone_name'],
          'label' => $row['partner_name'] ? 
            ($row['zone_name'] ? "{$row['partner_name']} > {$row['zone_name']}" : $row['partner_name']) :
            ($row['zone_name'] ? $row['zone_name'] : 'Unknown')
        ];
      }
    }
    
    json([
      'ok' => true,
      'configs' => $configs
    ]);
  }

  // DELETE /admin/bot-defense/config/custom?partner_id=...&zone_id=...
  // Delete custom bot config for partner/zone
  if ($uri === '/admin/bot-defense/config/custom' && $method === 'DELETE') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    // Note: bot_config_custom table created via sql/2026-02-add-bot-defense-tables.sql migration
    // Do not use CREATE TABLE here - ct_app_rw user (H-05) does not have CREATE privileges
    
    $partnerParsed = parse_optional_positive_int($_GET['partner_id'] ?? null);
    $zoneParsed = parse_optional_positive_int($_GET['zone_id'] ?? null);
    if ($partnerParsed === false || $zoneParsed === false) {
      json(['ok' => false, 'error' => 'invalid_scope_ids'], 422);
    }

    $partnerId = $partnerParsed;
    $zoneId = $zoneParsed;
    
    if (!$partnerId && !$zoneId) {
      json(['ok' => false, 'error' => 'partner_id or zone_id required'], 400);
      exit;
    }

    $scope = resolve_bot_config_scope($pdo, $partnerId, $zoneId);
    if (!$scope['ok']) {
      json(['ok' => false, 'error' => $scope['error']], $scope['status']);
    }
    $partnerId = $scope['partner_id'];
    $zoneId = $scope['zone_id'];
    
    $deleteSt = $pdo->prepare("
      DELETE FROM bot_config_custom 
      WHERE partner_id <=> ? AND zone_id <=> ?
    ");
    $deleteSt->execute([$partnerId, $zoneId]);
    
    json([
      'ok' => true,
      'message' => 'Custom configuration deleted'
    ]);
  }

  // GET /admin/bot-defense/config/orphans
  // Report orphaned/inconsistent bot config rows
  if ($uri === '/admin/bot-defense/config/orphans' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    $summarySt = $pdo->query("SELECT
      SUM(CASE WHEN bc.partner_id IS NOT NULL AND p.id IS NULL THEN 1 ELSE 0 END) AS missing_partner_rows,
      SUM(CASE WHEN bc.zone_id IS NOT NULL AND pz.id IS NULL THEN 1 ELSE 0 END) AS missing_zone_rows,
      SUM(CASE WHEN bc.zone_id IS NOT NULL AND pz.id IS NOT NULL AND bc.partner_id IS NOT NULL AND pz.partner_id <> bc.partner_id THEN 1 ELSE 0 END) AS mismatched_zone_partner_rows
      FROM bot_config_custom bc
      LEFT JOIN partners p ON bc.partner_id = p.id
      LEFT JOIN partner_zones pz ON bc.zone_id = pz.id");
    $summary = $summarySt ? ($summarySt->fetch(PDO::FETCH_ASSOC) ?: []) : [];

    $rowsSt = $pdo->query("SELECT
        bc.id,
        bc.partner_id,
        bc.zone_id,
        bc.config_key,
        bc.config_value,
        p.id AS partner_exists,
        pz.id AS zone_exists,
        pz.partner_id AS zone_partner_id
      FROM bot_config_custom bc
      LEFT JOIN partners p ON bc.partner_id = p.id
      LEFT JOIN partner_zones pz ON bc.zone_id = pz.id
      WHERE (bc.partner_id IS NOT NULL AND p.id IS NULL)
         OR (bc.zone_id IS NOT NULL AND pz.id IS NULL)
         OR (bc.zone_id IS NOT NULL AND pz.id IS NOT NULL AND bc.partner_id IS NOT NULL AND pz.partner_id <> bc.partner_id)
      ORDER BY bc.id DESC
      LIMIT 200");

    $orphans = [];
    if ($rowsSt) {
      while ($row = $rowsSt->fetch(PDO::FETCH_ASSOC)) {
        $reason = [];
        if (!empty($row['partner_id']) && empty($row['partner_exists'])) {
          $reason[] = 'missing_partner';
        }
        if (!empty($row['zone_id']) && empty($row['zone_exists'])) {
          $reason[] = 'missing_zone';
        }
        if (!empty($row['zone_id']) && !empty($row['zone_exists']) && !empty($row['partner_id']) && (int)$row['partner_id'] !== (int)($row['zone_partner_id'] ?? 0)) {
          $reason[] = 'zone_partner_mismatch';
        }
        $row['reason'] = implode(',', $reason);
        $orphans[] = $row;
      }
    }

    $missingPartnerRows = (int)($summary['missing_partner_rows'] ?? 0);
    $missingZoneRows = (int)($summary['missing_zone_rows'] ?? 0);
    $mismatchedRows = (int)($summary['mismatched_zone_partner_rows'] ?? 0);

    json([
      'ok' => true,
      'summary' => [
        'missing_partner_rows' => $missingPartnerRows,
        'missing_zone_rows' => $missingZoneRows,
        'mismatched_zone_partner_rows' => $mismatchedRows,
        'total_orphan_rows' => $missingPartnerRows + $missingZoneRows + $mismatchedRows,
      ],
      'orphans' => $orphans,
    ]);
  }

  // GET /admin/bot-defense/patterns/custom
  // Get list of custom bot block patterns
  if ($uri === '/admin/bot-defense/patterns/custom' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    $st = $pdo->query("SELECT * FROM bot_patterns_custom ORDER BY created_at DESC");
    $patterns = [];
    
    if ($st) {
      while ($row = $st->fetch()) {
        $patterns[] = [
          'id' => (int)$row['id'],
          'pattern' => $row['pattern'],
          'description' => $row['description'],
          'active' => (bool)$row['active'],
          'added_by' => $row['added_by'],
          'created_at' => $row['created_at']
        ];
      }
    }
    
    json(['ok' => true, 'patterns' => $patterns]);
  }

  // POST /admin/bot-defense/patterns/custom
  // Add a new custom bot block pattern
  if ($uri === '/admin/bot-defense/patterns/custom' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    $json = json_decode(file_get_contents('php://input'), true);
    
    if (!$json || !isset($json['pattern']) || empty(trim($json['pattern']))) {
      json(['ok' => false, 'error' => 'Pattern is required'], 400);
      exit;
    }
    
    $pdo = db();
    $pattern = strtolower(trim($json['pattern']));
    $description = isset($json['description']) ? trim($json['description']) : null;
    $addedBy = $auth['username'] ?? 'superadmin';
    
    try {
      $st = $pdo->prepare("INSERT INTO bot_patterns_custom (pattern, description, added_by) VALUES (?, ?, ?)");
      $st->execute([$pattern, $description, $addedBy]);
      
      json([
        'ok' => true,
        'id' => (int)$pdo->lastInsertId(),
        'message' => 'Block pattern added successfully'
      ]);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') { // Duplicate entry
        json(['ok' => false, 'error' => 'Pattern already exists'], 409);
      } else {
        json(['ok' => false, 'error' => 'Failed to add pattern'], 500);
      }
    }
  }

  // DELETE /admin/bot-defense/patterns/custom/:id
  // Delete a custom bot pattern
  if (preg_match('#^/admin/bot-defense/patterns/custom/(\d+)$#', $uri, $matches) && $method === 'DELETE') {
    $auth = require_session_auth(['superadmin']);
    $patternId = (int)$matches[1];
    $pdo = db();
    
    $st = $pdo->prepare("DELETE FROM bot_patterns_custom WHERE id = ?");
    $st->execute([$patternId]);
    
    json(['ok' => true, 'message' => 'Pattern deleted successfully']);
  }

  // PATCH /admin/bot-defense/patterns/custom/:id
  // Toggle active status of a custom bot pattern
  if (preg_match('#^/admin/bot-defense/patterns/custom/(\d+)$#', $uri, $matches) && $method === 'PATCH') {
    $auth = require_session_auth(['superadmin']);
    $patternId = (int)$matches[1];
    $json = json_decode(file_get_contents('php://input'), true);
    
    if (!$json || !isset($json['active'])) {
      json(['ok' => false, 'error' => 'active field required'], 400);
      exit;
    }
    
    $pdo = db();
    $active = $json['active'] ? 1 : 0;
    
    $st = $pdo->prepare("UPDATE bot_patterns_custom SET active = ? WHERE id = ?");
    $st->execute([$active, $patternId]);
    
    json(['ok' => true, 'message' => 'Pattern status updated']);
  }

  // GET /admin/bot-defense/patterns/trusted
  // Get list of trusted user-agent allowlist patterns
  if ($uri === '/admin/bot-defense/patterns/trusted' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    $st = $pdo->query("SELECT * FROM bot_patterns_trusted ORDER BY created_at DESC");
    $patterns = [];

    if ($st) {
      while ($row = $st->fetch()) {
        $patterns[] = [
          'id' => (int)$row['id'],
          'pattern' => $row['pattern'],
          'description' => $row['description'],
          'active' => (bool)$row['active'],
          'added_by' => $row['added_by'],
          'created_at' => $row['created_at']
        ];
      }
    }

    json(['ok' => true, 'patterns' => $patterns]);
  }

  // POST /admin/bot-defense/patterns/trusted
  // Add a trusted user-agent allowlist pattern
  if ($uri === '/admin/bot-defense/patterns/trusted' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    $json = json_decode(file_get_contents('php://input'), true);

    if (!$json || !isset($json['pattern']) || empty(trim($json['pattern']))) {
      json(['ok' => false, 'error' => 'Pattern is required'], 400);
      exit;
    }

    $pdo = db();
    $pattern = strtolower(trim($json['pattern']));
    $description = isset($json['description']) ? trim($json['description']) : null;
    $addedBy = $auth['email'] ?? ($auth['name'] ?? 'superadmin');

    try {
      $st = $pdo->prepare("INSERT INTO bot_patterns_trusted (pattern, description, added_by) VALUES (?, ?, ?)");
      $st->execute([$pattern, $description, $addedBy]);

      json([
        'ok' => true,
        'id' => (int)$pdo->lastInsertId(),
        'message' => 'Trusted pattern added successfully'
      ]);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') {
        json(['ok' => false, 'error' => 'Pattern already exists'], 409);
      } else {
        json(['ok' => false, 'error' => 'Failed to add trusted pattern'], 500);
      }
    }
  }

  // DELETE /admin/bot-defense/patterns/trusted/:id
  // Delete a trusted user-agent pattern
  if (preg_match('#^/admin/bot-defense/patterns/trusted/(\d+)$#', $uri, $matches) && $method === 'DELETE') {
    $auth = require_session_auth(['superadmin']);
    $patternId = (int)$matches[1];
    $pdo = db();

    $st = $pdo->prepare("DELETE FROM bot_patterns_trusted WHERE id = ?");
    $st->execute([$patternId]);

    json(['ok' => true, 'message' => 'Trusted pattern deleted successfully']);
  }

  // PATCH /admin/bot-defense/patterns/trusted/:id
  // Toggle active status of a trusted user-agent pattern
  if (preg_match('#^/admin/bot-defense/patterns/trusted/(\d+)$#', $uri, $matches) && $method === 'PATCH') {
    $auth = require_session_auth(['superadmin']);
    $patternId = (int)$matches[1];
    $json = json_decode(file_get_contents('php://input'), true);

    if (!$json || !isset($json['active'])) {
      json(['ok' => false, 'error' => 'active field required'], 400);
      exit;
    }

    $pdo = db();
    $active = $json['active'] ? 1 : 0;

    $st = $pdo->prepare("UPDATE bot_patterns_trusted SET active = ? WHERE id = ?");
    $st->execute([$active, $patternId]);

    json(['ok' => true, 'message' => 'Trusted pattern status updated']);
  }

  // GET /admin/bot-defense/whitelist
  // Get IP whitelist
  if ($uri === '/admin/bot-defense/whitelist' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();

    if (bot_whitelist_has_expires_at($pdo)) {
      $st = $pdo->query("SELECT *, (active = 1 AND (expires_at IS NULL OR expires_at > NOW())) AS is_effective, (expires_at IS NOT NULL AND expires_at <= NOW()) AS is_expired FROM bot_whitelist ORDER BY created_at DESC");
    } else {
      $st = $pdo->query("SELECT *, (active = 1) AS is_effective, 0 AS is_expired FROM bot_whitelist ORDER BY created_at DESC");
    }
    $ips = [];
    
    if ($st) {
      while ($row = $st->fetch()) {
        $ips[] = [
          'id' => (int)$row['id'],
          'ip' => $row['ip'],
          'description' => $row['description'],
          'active' => (bool)$row['active'],
          'is_effective' => isset($row['is_effective']) ? ((int)$row['is_effective'] === 1) : (bool)$row['active'],
          'is_expired' => isset($row['is_expired']) ? ((int)$row['is_expired'] === 1) : false,
          'added_by' => $row['added_by'],
          'created_at' => $row['created_at'],
          'expires_at' => $row['expires_at'] ?? null,
          'updated_at' => $row['updated_at'] ?? null,
        ];
      }
    }
    
    json(['ok' => true, 'whitelist' => $ips]);
  }

  // POST /admin/bot-defense/whitelist
  // Add IP to whitelist
  if ($uri === '/admin/bot-defense/whitelist' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    $json = json_decode(file_get_contents('php://input'), true);
    
    if (!$json || !isset($json['ip']) || empty(trim($json['ip']))) {
      json(['ok' => false, 'error' => 'IP address is required'], 400);
      exit;
    }
    
    $pdo = db();
    $ip = trim($json['ip']);
    $description = isset($json['description']) ? trim($json['description']) : null;
    $addedBy = $auth['email'] ?? ($auth['name'] ?? 'superadmin');
    $expiresInHours = isset($json['expires_in_hours']) ? (int)$json['expires_in_hours'] : 0;
    
    // Basic IP validation
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
      json(['ok' => false, 'error' => 'Invalid IP address format'], 400);
      exit;
    }
    
    try {
      $hasExpiresAt = bot_whitelist_has_expires_at($pdo);

      if ($hasExpiresAt && $expiresInHours > 0) {
        $st = $pdo->prepare("INSERT INTO bot_whitelist (ip, description, active, added_by, expires_at) VALUES (?, ?, 1, ?, DATE_ADD(NOW(), INTERVAL ? HOUR)) ON DUPLICATE KEY UPDATE description = VALUES(description), active = 1, added_by = VALUES(added_by), expires_at = VALUES(expires_at), updated_at = CURRENT_TIMESTAMP");
        $st->execute([$ip, $description, $addedBy, $expiresInHours]);
      } else {
        if ($hasExpiresAt) {
          $st = $pdo->prepare("INSERT INTO bot_whitelist (ip, description, active, added_by, expires_at) VALUES (?, ?, 1, ?, NULL) ON DUPLICATE KEY UPDATE description = VALUES(description), active = 1, added_by = VALUES(added_by), expires_at = NULL, updated_at = CURRENT_TIMESTAMP");
        } else {
          $st = $pdo->prepare("INSERT INTO bot_whitelist (ip, description, active, added_by) VALUES (?, ?, 1, ?) ON DUPLICATE KEY UPDATE description = VALUES(description), active = 1, added_by = VALUES(added_by), updated_at = CURRENT_TIMESTAMP");
        }
        $st->execute([$ip, $description, $addedBy]);
      }

      $unblocked = IpBlockManager::unblockIp($ip, (string)$addedBy);

      $message = $expiresInHours > 0
        ? "IP added to whitelist successfully (temporary)"
        : "IP added to whitelist successfully (permanent)";
      
      json([
        'ok' => true,
        'message' => $message,
        'unblocked' => (bool)$unblocked,
      ]);
    } catch (Throwable $e) {
      json(['ok' => false, 'error' => 'Failed to add IP'], 500);
    }
  }

  // PATCH /admin/bot-defense/whitelist/:id
  // Reactivate/refresh whitelist entry as temporary or permanent.
  if (preg_match('#^/admin/bot-defense/whitelist/(\d+)$#', $uri, $matches) && $method === 'PATCH') {
    $auth = require_session_auth(['superadmin']);
    $id = (int)$matches[1];
    $json = json_decode(file_get_contents('php://input'), true) ?: [];
    $expiresInHours = isset($json['expires_in_hours']) ? (int)$json['expires_in_hours'] : 0;
    $description = isset($json['description']) ? trim((string)$json['description']) : null;
    $addedBy = (string)($auth['email'] ?? ($auth['name'] ?? 'superadmin'));

    $pdo = db();

    try {
      $stGet = $pdo->prepare("SELECT ip, description FROM bot_whitelist WHERE id = ? LIMIT 1");
      $stGet->execute([$id]);
      $row = $stGet->fetch(PDO::FETCH_ASSOC);

      if (!$row) {
        json(['ok' => false, 'error' => 'Whitelist entry not found'], 404);
        exit;
      }

      $entryIp = (string)$row['ip'];
      $finalDescription = $description;
      if ($finalDescription === null || $finalDescription === '') {
        $finalDescription = isset($row['description']) ? trim((string)$row['description']) : null;
      }

      if (bot_whitelist_has_expires_at($pdo)) {
        if ($expiresInHours > 0) {
          $st = $pdo->prepare("UPDATE bot_whitelist SET active = 1, description = ?, added_by = ?, expires_at = DATE_ADD(NOW(), INTERVAL ? HOUR), updated_at = CURRENT_TIMESTAMP WHERE id = ?");
          $st->execute([$finalDescription, $addedBy, $expiresInHours, $id]);
        } else {
          $st = $pdo->prepare("UPDATE bot_whitelist SET active = 1, description = ?, added_by = ?, expires_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
          $st->execute([$finalDescription, $addedBy, $id]);
        }
      } else {
        $st = $pdo->prepare("UPDATE bot_whitelist SET active = 1, description = ?, added_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
        $st->execute([$finalDescription, $addedBy, $id]);
      }

      $unblocked = IpBlockManager::unblockIp($entryIp, $addedBy);

      json([
        'ok' => true,
        'ip' => $entryIp,
        'unblocked' => (bool)$unblocked,
        'message' => $expiresInHours > 0
          ? 'Whitelist entry reactivated temporarily'
          : 'Whitelist entry reactivated permanently',
      ]);
    } catch (Throwable $e) {
      json(['ok' => false, 'error' => 'Failed to update whitelist entry'], 500);
    }
  }

  // POST /admin/bot-defense/whitelist/me
  // Temporarily whitelist the current admin/superadmin IP for 24 hours.
  if ($uri === '/admin/bot-defense/whitelist/me' && $method === 'POST') {
    $auth = require_session_auth(['admin', 'superadmin']);
    $pdo = db();

    $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
    if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
      json(['ok' => false, 'error' => 'Could not determine current IP address'], 400);
      exit;
    }

    $addedBy = (string)($auth['email'] ?? ($auth['name'] ?? ($auth['role'] ?? 'admin')));
    $description = 'Self-service temporary whitelist (24h)';
    $hasExpiresAt = bot_whitelist_has_expires_at($pdo);

    try {
      if ($hasExpiresAt) {
        $st = $pdo->prepare("INSERT INTO bot_whitelist (ip, description, active, added_by, expires_at) VALUES (?, ?, 1, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR)) ON DUPLICATE KEY UPDATE description = VALUES(description), active = 1, added_by = VALUES(added_by), expires_at = VALUES(expires_at), updated_at = CURRENT_TIMESTAMP");
        $st->execute([$ip, $description, $addedBy]);
      } else {
        $st = $pdo->prepare("INSERT INTO bot_whitelist (ip, description, active, added_by) VALUES (?, ?, 1, ?) ON DUPLICATE KEY UPDATE description = VALUES(description), active = 1, added_by = VALUES(added_by), updated_at = CURRENT_TIMESTAMP");
        $st->execute([$ip, $description, $addedBy]);
      }

      $expiresAt = null;
      if ($hasExpiresAt) {
        $stExp = $pdo->prepare("SELECT expires_at FROM bot_whitelist WHERE ip = ? LIMIT 1");
        $stExp->execute([$ip]);
        $rowExp = $stExp->fetch(PDO::FETCH_ASSOC);
        $expiresAt = $rowExp['expires_at'] ?? null;
      }

      $unblocked = IpBlockManager::unblockIp($ip, $addedBy);

      SecurityLogger::log(
        'ip_self_whitelist',
        'info',
        'Admin invoked Clear Me to whitelist current IP for 24h',
        [
          'ip' => $ip,
          'expires_at' => $expiresAt,
          'unblocked' => (bool)$unblocked,
        ],
        [
          'id' => $auth['id'] ?? null,
          'email' => $auth['email'] ?? null,
          'role' => $auth['role'] ?? null,
        ]
      );

      json([
        'ok' => true,
        'ip' => $ip,
        'expires_at' => $expiresAt,
        'unblocked' => (bool)$unblocked,
        'message' => 'Current IP has been whitelisted for 24 hours',
      ]);
    } catch (Throwable $e) {
      error_log('Clear Me whitelist failure: ' . $e->getMessage());
      json(['ok' => false, 'error' => 'Failed to whitelist current IP'], 500);
    }
  }

  // DELETE /admin/bot-defense/whitelist/:id
  // Remove IP from whitelist
  if (preg_match('#^/admin/bot-defense/whitelist/(\d+)$#', $uri, $matches) && $method === 'DELETE') {
    $auth = require_session_auth(['superadmin']);
    $id = (int)$matches[1];
    $pdo = db();
    
    $st = $pdo->prepare("DELETE FROM bot_whitelist WHERE id = ?");
    $st->execute([$id]);
    
    json(['ok' => true, 'message' => 'IP removed from whitelist']);
  }

  // GET /admin/bot-defense/blacklist
  // Get IP blacklist
  if ($uri === '/admin/bot-defense/blacklist' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    $st = $pdo->query("SELECT * FROM bot_blacklist ORDER BY created_at DESC");
    $ips = [];
    
    if ($st) {
      while ($row = $st->fetch()) {
        $ips[] = [
          'id' => (int)$row['id'],
          'ip' => $row['ip'],
          'reason' => $row['reason'],
          'active' => (bool)$row['active'],
          'added_by' => $row['added_by'],
          'created_at' => $row['created_at']
        ];
      }
    }
    
    json(['ok' => true, 'blacklist' => $ips]);
  }

  // POST /admin/bot-defense/blacklist
  // Add IP to blacklist
  if ($uri === '/admin/bot-defense/blacklist' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    $json = json_decode(file_get_contents('php://input'), true);
    
    if (!$json || !isset($json['ip']) || empty(trim($json['ip']))) {
      json(['ok' => false, 'error' => 'IP address is required'], 400);
      exit;
    }
    
    $pdo = db();
    $ip = trim($json['ip']);
    $reason = isset($json['reason']) ? trim($json['reason']) : null;
    $addedBy = $auth['username'] ?? 'superadmin';
    
    // Basic IP validation
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
      json(['ok' => false, 'error' => 'Invalid IP address format'], 400);
      exit;
    }
    
    try {
      $st = $pdo->prepare("INSERT INTO bot_blacklist (ip, reason, added_by) VALUES (?, ?, ?)");
      $st->execute([$ip, $reason, $addedBy]);
      
      json([
        'ok' => true,
        'id' => (int)$pdo->lastInsertId(),
        'message' => 'IP added to blacklist successfully'
      ]);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') {
        json(['ok' => false, 'error' => 'IP already blacklisted'], 409);
      } else {
        json(['ok' => false, 'error' => 'Failed to add IP'], 500);
      }
    }
  }

  // DELETE /admin/bot-defense/blacklist/:id
  // Remove IP from blacklist
  if (preg_match('#^/admin/bot-defense/blacklist/(\d+)$#', $uri, $matches) && $method === 'DELETE') {
    $auth = require_session_auth(['superadmin']);
    $id = (int)$matches[1];
    $pdo = db();
    
    $st = $pdo->prepare("DELETE FROM bot_blacklist WHERE id = ?");
    $st->execute([$id]);
    
    json(['ok' => true, 'message' => 'IP removed from blacklist']);
  }

  // GET /admin/bot-defense/export?type=...&since=...
  // Export blocked requests as CSV
  if ($uri === '/admin/bot-defense/export' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    $pdo = db();
    
    $type = isset($_GET['type']) && $_GET['type'] ? trim($_GET['type']) : null;
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $limit = isset($_GET['limit']) ? min((int)$_GET['limit'], 10000) : 1000;
    
    $hasTrackingUrlId = false;
    $hasZoneId = false;
    try {
      $colSt = $pdo->prepare("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blocked_requests' AND COLUMN_NAME IN ('tracking_url_id','zone_id')");
      $colSt->execute();
      while ($row = $colSt->fetch()) {
        if ($row['COLUMN_NAME'] === 'tracking_url_id') { $hasTrackingUrlId = true; }
        if ($row['COLUMN_NAME'] === 'zone_id') { $hasZoneId = true; }
      }
    } catch (Throwable $_) {
      $hasTrackingUrlId = false;
      $hasZoneId = false;
    }

    if ($hasTrackingUrlId) {
      $zoneJoinExpr = $hasZoneId ? "COALESCE(br.zone_id, tu.zone_id)" : "tu.zone_id";
      $sql = "SELECT br.*, 
                tu.zone_id AS tracking_zone_id,
                tu.ad_item_id AS tracking_ad_item_id,
                pz.name AS zone_name,
                ai.name AS ad_item_name,
                pz.partner_id AS partner_id_from_zone
              FROM blocked_requests br
              LEFT JOIN tracking_urls tu ON tu.id = br.tracking_url_id
              LEFT JOIN partner_zones pz ON pz.id = {$zoneJoinExpr}
              LEFT JOIN ad_items ai ON ai.id = tu.ad_item_id
              WHERE 1=1";
    } elseif ($hasZoneId) {
      $sql = "SELECT br.*, 
                NULL AS tracking_zone_id,
                NULL AS tracking_ad_item_id,
                pz.name AS zone_name,
                NULL AS ad_item_name,
                pz.partner_id AS partner_id_from_zone
              FROM blocked_requests br
              LEFT JOIN partner_zones pz ON pz.id = br.zone_id
              WHERE 1=1";
    } else {
      $sql = "SELECT br.*, 
                NULL AS tracking_zone_id,
                NULL AS tracking_ad_item_id,
                NULL AS zone_name,
                NULL AS ad_item_name,
                NULL AS partner_id_from_zone
              FROM blocked_requests br
              WHERE 1=1";
    }
    $params = [];
    
    if ($type) {
      $sql .= " AND br.type = ?";
      $params[] = $type;
    }
    if ($since) {
      $sql .= " AND br.ts >= ?";
      $params[] = $since;
    }
    
    $sql .= " ORDER BY br.ts DESC LIMIT $limit";
    
    $st = $pdo->prepare($sql);
    $st->execute($params);
    
    // Set CSV headers
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="blocked_requests_' . date('Y-m-d_His') . '.csv"');
    
    $output = fopen('php://output', 'w');
    
    // CSV headers
    fputcsv($output, ['ID', 'Timestamp', 'Type', 'IP', 'User Agent', 'URL', 'Reason', 'Score', 'Partner ID', 'Zone ID', 'Zone Name', 'Ad Item ID', 'Ad Item Name', 'Advertiser ID', 'Campaign ID']);
    
    // CSV data
    while ($row = $st->fetch()) {
      $zoneId = $row['zone_id'] ?? $row['tracking_zone_id'] ?? null;
      $partnerId = $row['partner_id'] ?? $row['partner_id_from_zone'] ?? null;
      fputcsv($output, [
        $row['id'],
        $row['ts'],
        $row['type'],
        $row['ip'],
        $row['user_agent'],
        $row['url'],
        $row['reason'],
        $row['score'],
        $partnerId,
        $zoneId,
        $row['zone_name'] ?? null,
        $row['tracking_ad_item_id'] ?? null,
        $row['ad_item_name'] ?? null,
        $row['advertiser_id'] ?? null,
        $row['campaign_id'] ?? null
      ]);
    }
    
    fclose($output);
    exit;
  }

  // ===== SECURITY EVENTS ENDPOINTS =====
  
  // GET /admin/security-events/summary
  // Get 24-hour summary of security events
  if ($uri === '/admin/security-events/summary' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    
    $today = date('Y-m-d');
    $events = SecurityLogger::query($today);
    $stats = SecurityLogger::getStats($today);
    
    // Calculate 24h stats
    $total24h = $stats['total'] ?? 0;
    $critical24h = ($stats['by_severity']['critical'] ?? 0) + ($stats['by_severity']['error'] ?? 0);
    $warnings24h = $stats['by_severity']['warning'] ?? 0;
    $info24h = $stats['by_severity']['info'] ?? 0;
    
    // Find top event type
    $topType = null;
    if (!empty($stats['by_event_type'])) {
      $topTypeArr = array_slice($stats['by_event_type'], 0, 1, true);
      $topTypeKey = array_key_first($topTypeArr);
      $topType = [
        'type' => $topTypeKey,
        'count' => $topTypeArr[$topTypeKey]
      ];
    }
    
    json([
      'ok' => true,
      'total24h' => $total24h,
      'critical24h' => $critical24h,
      'warnings24h' => $warnings24h,
      'info24h' => $info24h,
      'topType' => $topType
    ]);
  }
  
  // GET /admin/security-events/query?date=YYYY-MM-DD&event_type=...&severity=...&ip=...
  // Query security events for a given date with optional filters
  if ($uri === '/admin/security-events/query' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    
    $date = isset($_GET['date']) ? trim($_GET['date']) : date('Y-m-d');
    
    // Build filters from query params
    $filters = [];
    if (isset($_GET['event_type']) && $_GET['event_type'] !== '') {
      $filters['event_type'] = trim($_GET['event_type']);
    }
    if (isset($_GET['severity']) && $_GET['severity'] !== '') {
      $filters['severity'] = trim($_GET['severity']);
    }
    if (isset($_GET['ip']) && $_GET['ip'] !== '') {
      $filters['ip'] = trim($_GET['ip']);
    }
    
    // Query events
    $events = SecurityLogger::query($date, $filters);
    
    // Get stats (unfiltered for the whole day)
    $stats = SecurityLogger::getStats($date);
    
    json([
      'ok' => true,
      'date' => $date,
      'events' => $events,
      'stats' => $stats,
      'filters' => $filters
    ]);
  }

  // ===== OBSERVABILITY ENDPOINTS =====

  // GET /admin/observability/summary?days=7
  // Aggregated summary for dashboarding auth failures, rate-limit spikes, bot blocks.
  if ($uri === '/admin/observability/summary' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);

    try {
      $days = isset($_GET['days']) ? (int)$_GET['days'] : 7;
      $days = max(1, min(30, $days));

      $allEvents = [];
      $byDate = [];
      for ($i = 0; $i < $days; $i++) {
        $date = date('Y-m-d', strtotime("-{$i} days"));
        $events = observability_read_events_for_date($date);
        $summary = observability_summary($events);

        $byDate[$date] = [
          'total' => $summary['total'],
          'auth_failures' => $summary['dashboard']['auth_failures'],
          'rate_limit_events' => $summary['dashboard']['rate_limit_events'],
          'bot_blocks' => $summary['dashboard']['bot_blocks'],
          'critical_events' => $summary['dashboard']['critical_events'],
        ];
        if (!empty($events)) {
          $allEvents = array_merge($allEvents, $events);
        }
      }

      $overall = observability_summary($allEvents);

      json([
        'ok' => true,
        'days' => $days,
        'overall' => $overall,
        'by_date' => $byDate,
      ]);
    } catch (Throwable $e) {
      safe_error($e, 'observability_summary_error', 'Observability summary error');
    }
  }

  // GET /admin/observability/query?date=YYYY-MM-DD&event_type=...&severity=...&ip=...&source=...&limit=100&offset=0
  // Query centralized observability events with filters.
  if ($uri === '/admin/observability/query' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);

    try {
      $date = isset($_GET['date']) ? trim((string)$_GET['date']) : date('Y-m-d');
      if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
        json(['ok' => false, 'error' => 'invalid_date'], 422);
      }

      $filters = [
        'event_type' => isset($_GET['event_type']) ? trim((string)$_GET['event_type']) : '',
        'severity' => isset($_GET['severity']) ? trim((string)$_GET['severity']) : '',
        'ip' => isset($_GET['ip']) ? trim((string)$_GET['ip']) : '',
        'source' => isset($_GET['source']) ? trim((string)$_GET['source']) : '',
      ];

      $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
      $offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0;
      $limit = max(1, min(1000, $limit));
      $offset = max(0, $offset);

      $events = observability_read_events_for_date($date, $filters);
      $total = count($events);
      $paged = array_slice($events, $offset, $limit);

      json([
        'ok' => true,
        'date' => $date,
        'total' => $total,
        'limit' => $limit,
        'offset' => $offset,
        'filters' => $filters,
        'events' => $paged,
      ]);
    } catch (Throwable $e) {
      safe_error($e, 'observability_query_error', 'Observability query error');
    }
  }

  // ===== IP Blocking API Endpoints =====
  
  // GET /admin/blocked-ips?source=...&active_only=1
  // List all blocked IPs with optional filters
  if ($uri === '/admin/blocked-ips' && $method === 'GET') {
    $auth = require_session_auth(['superadmin']);
    
    $filters = [];
    if (isset($_GET['source']) && $_GET['source']) {
      $filters['source'] = $_GET['source'];
    }
    if (isset($_GET['active_only']) && $_GET['active_only']) {
      $filters['active_only'] = true;
    }
    
    $blocked = IpBlockManager::getAllBlocked($filters);
    $stats = IpBlockManager::getStats();
    
    json([
      'ok' => true,
      'blocked_ips' => $blocked,
      'stats' => $stats
    ]);
  }
  
  // POST /admin/blocked-ips/block
  // Block an IP address (manual blocking)
  if ($uri === '/admin/blocked-ips/block' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    
    $body = json_decode(file_get_contents('php://input'), true);
    $ip = $body['ip'] ?? '';
    $duration = $body['duration'] ?? '72h';
    $reason = $body['reason'] ?? 'Manual block';
    $notes = $body['notes'] ?? null;
    
    if (!$ip) {
      http_response_code(400);
      json(['ok' => false, 'error' => 'IP address required']);
      exit;
    }
    
    // Validate IP format
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
      http_response_code(400);
      json(['ok' => false, 'error' => 'Invalid IP address format']);
      exit;
    }
    
    // Parse duration
    $hours = 72; // default
    if ($duration === 'permanent' || $duration === '0') {
      $hours = 0;
    } elseif ($duration === '24h') {
      $hours = 24;
    } elseif ($duration === '48h') {
      $hours = 48;
    } elseif ($duration === '72h') {
      $hours = 72;
    } elseif ($duration === '1week') {
      $hours = 168;
    }
    
    $username = $auth['username'] ?? $auth['email'] ?? 'superadmin';
    $success = IpBlockManager::blockIp($ip, 'manual', $hours, $reason, $notes, $username);
    
    if ($success) {
      SecurityLogger::log('manual_ip_block', 'info', 'IP manually blocked', [
        'ip' => $ip,
        'duration' => $duration,
        'reason' => $reason,
        'blocked_by' => $username
      ]);
      
      json(['ok' => true, 'message' => 'IP blocked successfully']);
    } else {
      http_response_code(500);
      json(['ok' => false, 'error' => 'Failed to block IP']);
    }
  }
  
  // POST /admin/blocked-ips/unblock
  // Unblock an IP address
  if ($uri === '/admin/blocked-ips/unblock' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    
    $body = json_decode(file_get_contents('php://input'), true);
    $ip = $body['ip'] ?? '';
    
    if (!$ip) {
      http_response_code(400);
      json(['ok' => false, 'error' => 'IP address required']);
      exit;
    }
    
    $username = $auth['username'] ?? $auth['email'] ?? 'superadmin';
    $success = IpBlockManager::unblockIp($ip, $username);
    
    if ($success) {
      SecurityLogger::log('manual_ip_unblock', 'info', 'IP manually unblocked', [
        'ip' => $ip,
        'unblocked_by' => $username
      ]);
      
      json(['ok' => true, 'message' => 'IP unblocked successfully']);
    } else {
      http_response_code(500);
      json(['ok' => false, 'error' => 'Failed to unblock IP']);
    }
  }
  
  // POST /admin/blocked-ips/make-permanent
  // Convert a temporary block to permanent
  if ($uri === '/admin/blocked-ips/make-permanent' && $method === 'POST') {
    $auth = require_session_auth(['superadmin']);
    
    $body = json_decode(file_get_contents('php://input'), true);
    $ip = $body['ip'] ?? '';
    
    if (!$ip) {
      http_response_code(400);
      json(['ok' => false, 'error' => 'IP address required']);
      exit;
    }
    
    $success = IpBlockManager::makePermanent($ip);
    
    if ($success) {
      $username = $auth['username'] ?? $auth['email'] ?? 'superadmin';
      SecurityLogger::log('ip_block_made_permanent', 'info', 'IP block made permanent', [
        'ip' => $ip,
        'by' => $username
      ]);
      
      json(['ok' => true, 'message' => 'Block made permanent']);
    } else {
      http_response_code(500);
      json(['ok' => false, 'error' => 'Failed to make block permanent']);
    }
  }

  // GET /admin/reports/export-summary?since=...&until=...
  // CSV export for summary report
  if ($uri === '/admin/reports/export-summary' && $method === 'GET') {
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    
    $pdo = db();
    
    // Get summary data (reuse logic from summary endpoint)
    $clickSql = "SELECT COUNT(*) FROM click_logs WHERE 1=1";
    $clickParams = [];
    if ($since) {
      $clickSql .= " AND ts >= ?";
      $clickParams[] = $since . ' 00:00:00';
    }
    if ($until) {
      $clickSql .= " AND ts <= ?";
      $clickParams[] = $until . ' 23:59:59';
    }
    
    $clickStmt = $pdo->prepare($clickSql);
    $clickStmt->execute($clickParams);
    $totalClicks = (int)$clickStmt->fetchColumn();
    
    $impSql = "SELECT COUNT(*) FROM impression_logs WHERE 1=1";
    $impParams = [];
    if ($since) {
      $impSql .= " AND ts >= ?";
      $impParams[] = $since . ' 00:00:00';
    }
    if ($until) {
      $impSql .= " AND ts <= ?";
      $impParams[] = $until . ' 23:59:59';
    }
    
    $impStmt = $pdo->prepare($impSql);
    $impStmt->execute($impParams);
    $totalImpressions = (int)$impStmt->fetchColumn();
    
    $ctr = $totalImpressions > 0 ? round(($totalClicks / $totalImpressions) * 100, 2) : 0;
    
    // Generate CSV
    $filename = 'summary_report_' . date('Y-m-d_His') . '.csv';
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    header('Pragma: no-cache');
    header('Expires: 0');
    
    $output = fopen('php://output', 'w');
    
    // CSV header
    fputcsv($output, ['Metric', 'Value']);
    
    // Data rows
    fputcsv($output, ['Total Clicks', $totalClicks]);
    fputcsv($output, ['Total Impressions', $totalImpressions]);
    fputcsv($output, ['Click-Through Rate (%)', $ctr]);
    fputcsv($output, ['Date Range Start', $since ?: 'Any']);
    fputcsv($output, ['Date Range End', $until ?: 'Any']);
    fputcsv($output, ['Report Generated', date('Y-m-d H:i:s')]);
    
    fclose($output);
    exit;
  }

  // GET /admin/reports/export-breakdown?group_by=...&since=...&until=...
  // CSV export for breakdown report
  if ($uri === '/admin/reports/export-breakdown' && $method === 'GET') {
    $groupBy = isset($_GET['group_by']) ? trim($_GET['group_by']) : 'advertiser';
    $since = isset($_GET['since']) && $_GET['since'] ? trim($_GET['since']) : null;
    $until = isset($_GET['until']) && $_GET['until'] ? trim($_GET['until']) : null;
    
    if (!in_array($groupBy, ['advertiser', 'campaign', 'zone'])) {
      json(['error' => 'invalid_group_by'], 400);
    }
    
    $pdo = db();
    $breakdown = [];
    
    // Reuse breakdown logic from breakdown endpoint
    if ($groupBy === 'advertiser') {
      $sql = "
        SELECT 
          a.id, 
          a.name,
          COUNT(DISTINCT c.id) as campaign_count,
          COUNT(DISTINCT ai.id) as ad_item_count
        FROM advertisers a
        LEFT JOIN campaigns c ON c.advertiser_id = a.id
        LEFT JOIN ad_items ai ON ai.campaign_id = c.id
        GROUP BY a.id, a.name
        ORDER BY a.name ASC
      ";
      
      $stmt = $pdo->query($sql);
      $advertisers = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($advertisers as $adv) {
        $clickSql = "
          SELECT COUNT(*) 
          FROM click_logs cl
          JOIN tracking_urls t ON t.id = cl.tracking_url_id
          JOIN ad_items ai ON ai.id = t.ad_item_id
          JOIN campaigns c ON c.id = ai.campaign_id
          WHERE c.advertiser_id = ?
        ";
        $clickParams = [$adv['id']];
        if ($since) {
          $clickSql .= " AND cl.ts >= ?";
          $clickParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $clickSql .= " AND cl.ts <= ?";
          $clickParams[] = $until . ' 23:59:59';
        }
        
        $clickStmt = $pdo->prepare($clickSql);
        $clickStmt->execute($clickParams);
        $clicks = (int)$clickStmt->fetchColumn();
        
        $impSql = "
          SELECT COUNT(*) 
          FROM impression_logs il
          JOIN tracking_urls t ON t.id = il.tracking_url_id
          JOIN ad_items ai ON ai.id = t.ad_item_id
          JOIN campaigns c ON c.id = ai.campaign_id
          WHERE c.advertiser_id = ?
        ";
        $impParams = [$adv['id']];
        if ($since) {
          $impSql .= " AND il.ts >= ?";
          $impParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $impSql .= " AND il.ts <= ?";
          $impParams[] = $until . ' 23:59:59';
        }
        
        $impStmt = $pdo->prepare($impSql);
        $impStmt->execute($impParams);
        $impressions = (int)$impStmt->fetchColumn();
        
        $ctr = $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0;
        
        $breakdown[] = [
          'name' => $adv['name'],
          'campaigns' => (int)$adv['campaign_count'],
          'ad_items' => (int)$adv['ad_item_count'],
          'clicks' => $clicks,
          'impressions' => $impressions,
          'ctr' => $ctr
        ];
      }
    } elseif ($groupBy === 'campaign') {
      $sql = "
        SELECT 
          c.id, 
          c.name,
          a.name as advertiser_name,
          COUNT(DISTINCT ai.id) as ad_item_count
        FROM campaigns c
        JOIN advertisers a ON a.id = c.advertiser_id
        LEFT JOIN ad_items ai ON ai.campaign_id = c.id
        GROUP BY c.id, c.name, a.name
        ORDER BY c.name ASC
      ";
      
      $stmt = $pdo->query($sql);
      $campaigns = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($campaigns as $camp) {
        $clickSql = "
          SELECT COUNT(*) 
          FROM click_logs cl
          JOIN tracking_urls t ON t.id = cl.tracking_url_id
          JOIN ad_items ai ON ai.id = t.ad_item_id
          WHERE ai.campaign_id = ?
        ";
        $clickParams = [$camp['id']];
        if ($since) {
          $clickSql .= " AND cl.ts >= ?";
          $clickParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $clickSql .= " AND cl.ts <= ?";
          $clickParams[] = $until . ' 23:59:59';
        }
        
        $clickStmt = $pdo->prepare($clickSql);
        $clickStmt->execute($clickParams);
        $clicks = (int)$clickStmt->fetchColumn();
        
        $impSql = "
          SELECT COUNT(*) 
          FROM impression_logs il
          JOIN tracking_urls t ON t.id = il.tracking_url_id
          JOIN ad_items ai ON ai.id = t.ad_item_id
          WHERE ai.campaign_id = ?
        ";
        $impParams = [$camp['id']];
        if ($since) {
          $impSql .= " AND il.ts >= ?";
          $impParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $impSql .= " AND il.ts <= ?";
          $impParams[] = $until . ' 23:59:59';
        }
        
        $impStmt = $pdo->prepare($impSql);
        $impStmt->execute($impParams);
        $impressions = (int)$impStmt->fetchColumn();
        
        $ctr = $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0;
        
        $breakdown[] = [
          'name' => $camp['name'],
          'advertiser' => $camp['advertiser_name'],
          'ad_items' => (int)$camp['ad_item_count'],
          'clicks' => $clicks,
          'impressions' => $impressions,
          'ctr' => $ctr
        ];
      }
    } elseif ($groupBy === 'zone') {
      $sql = "
        SELECT 
          z.id, 
          z.name,
          p.name as partner_name
        FROM partner_zones z
        JOIN partners p ON p.id = z.partner_id
        ORDER BY z.name ASC
      ";
      
      $stmt = $pdo->query($sql);
      $zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
      
      foreach ($zones as $zone) {
        $clickSql = "
          SELECT COUNT(*) 
          FROM click_logs cl
          JOIN tracking_urls t ON t.id = cl.tracking_url_id
          WHERE t.zone_id = ?
        ";
        $clickParams = [$zone['id']];
        if ($since) {
          $clickSql .= " AND cl.ts >= ?";
          $clickParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $clickSql .= " AND cl.ts <= ?";
          $clickParams[] = $until . ' 23:59:59';
        }
        
        $clickStmt = $pdo->prepare($clickSql);
        $clickStmt->execute($clickParams);
        $clicks = (int)$clickStmt->fetchColumn();
        
        $impSql = "
          SELECT COUNT(*) 
          FROM impression_logs il
          JOIN tracking_urls t ON t.id = il.tracking_url_id
          WHERE t.zone_id = ?
        ";
        $impParams = [$zone['id']];
        if ($since) {
          $impSql .= " AND il.ts >= ?";
          $impParams[] = $since . ' 00:00:00';
        }
        if ($until) {
          $impSql .= " AND il.ts <= ?";
          $impParams[] = $until . ' 23:59:59';
        }
        
        $impStmt = $pdo->prepare($impSql);
        $impStmt->execute($impParams);
        $impressions = (int)$impStmt->fetchColumn();
        
        $ctr = $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0;
        
        $breakdown[] = [
          'name' => $zone['name'],
          'partner' => $zone['partner_name'],
          'clicks' => $clicks,
          'impressions' => $impressions,
          'ctr' => $ctr
        ];
      }
    }
    
    // Generate CSV
    $filename = $groupBy . '_report_' . date('Y-m-d_His') . '.csv';
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    header('Pragma: no-cache');
    header('Expires: 0');
    
    $output = fopen('php://output', 'w');
    
    // CSV header based on groupBy
    if ($groupBy === 'advertiser') {
      fputcsv($output, ['Advertiser', 'Campaigns', 'Ad Items', 'Clicks', 'Impressions', 'CTR (%)']);
      foreach ($breakdown as $row) {
        fputcsv($output, [$row['name'], $row['campaigns'], $row['ad_items'], $row['clicks'], $row['impressions'], $row['ctr']]);
      }
    } elseif ($groupBy === 'campaign') {
      fputcsv($output, ['Campaign', 'Advertiser', 'Ad Items', 'Clicks', 'Impressions', 'CTR (%)']);
      foreach ($breakdown as $row) {
        fputcsv($output, [$row['name'], $row['advertiser'], $row['ad_items'], $row['clicks'], $row['impressions'], $row['ctr']]);
      }
    } elseif ($groupBy === 'zone') {
      fputcsv($output, ['Zone', 'Partner', 'Clicks', 'Impressions', 'CTR (%)']);
      foreach ($breakdown as $row) {
        fputcsv($output, [$row['name'], $row['partner'], $row['clicks'], $row['impressions'], $row['ctr']]);
      }
    }
    
    // Add metadata footer
    fputcsv($output, []);
    fputcsv($output, ['Report Type', ucfirst($groupBy) . ' Breakdown']);
    fputcsv($output, ['Date Range', ($since ?: 'Any') . ' to ' . ($until ?: 'Any')]);
    fputcsv($output, ['Generated', date('Y-m-d H:i:s')]);
    
    fclose($output);
    exit;
  }


  // GET /admin/export/{clicks|impressions}
  if (preg_match('#^/admin/export/(clicks|impressions)$#', $uri, $m) && $method === 'GET') {
    $kind  = $m[1];
    $limit = isset($_GET['limit']) ? max(1, min(50000, (int)$_GET['limit'])) : 50000;
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="'.$kind.'_export.csv"');
    $out = fopen('php://output', 'w');
    $pdo = db();

    $dcol = dest_col();
    $destSel = $dcol ? "t.`$dcol` AS destination" : "NULL AS destination";

    if ($kind === 'clicks') {
      $sql = "SELECT cl.id, cl.tracking_url_id, cl.ts, cl.ip, cl.user_agent, cl.referrer, cl.host, cl.dest, cl.query_string, cl.http_method,
                     t.slug, $destSel, t.utm_source, t.utm_medium, t.utm_campaign, t.utm_content, t.utm_policy
              FROM click_logs cl
              LEFT JOIN tracking_urls t ON t.id = cl.tracking_url_id
              ORDER BY cl.id DESC";
    } else {
      $sql = "SELECT il.id, il.tracking_url_id, il.ts, il.ip, il.user_agent, il.referrer, il.host, il.http_method,
                     t.slug, $destSel, t.utm_source, t.utm_medium, t.utm_campaign, t.utm_content, t.utm_policy
              FROM impression_logs il
              LEFT JOIN tracking_urls t ON t.id = il.tracking_url_id
              ORDER BY il.id DESC";
    }

    $sql .= " LIMIT " . (int)$limit;
    $q = $pdo->query($sql);
    $first = true;
    while ($row = $q->fetch(PDO::FETCH_ASSOC)) {
      if ($first) { fputcsv($out, array_keys($row)); $first=false; }
      fputcsv($out, array_values($row));
    }
    fclose($out); exit;
  }

// CSV: /admin/export/advertisers
if ($method === 'GET' && $uri === '/admin/export/advertisers') {
  $pdo    = db();
  $search = $_GET['search'] ?? null;
  $limit  = isset($_GET['limit']) ? (int)$_GET['limit'] : 10000;
  if ($limit < 1) $limit = 1; if ($limit > 100000) $limit = 100000;

  $conds = []; $params = [];
  if ($search !== null && $search !== '') { $conds[] = 'name LIKE ?'; $params[] = '%'.$search.'%'; }
  $where = $conds ? ('WHERE '.implode(' AND ', $conds)) : '';

  $sql = "SELECT id, name, created_at, updated_at FROM advertisers $where ORDER BY id DESC LIMIT $limit";
  $st  = $pdo->prepare($sql); $st->execute($params);

  header('Content-Type: text/csv; charset=utf-8');
  header('Content-Disposition: attachment; filename=advertisers_'.date('Ymd_His').'.csv');
  $out = fopen('php://output','w');
  fputcsv($out, ['id','name','created_at','updated_at']);
  while ($row = $st->fetch(PDO::FETCH_NUM)) { fputcsv($out, $row); }
  fclose($out); exit;
}



// Admin CSV combined clicks+impressions (ID-based; no slug)
if ($method === 'GET' && $uri === '/admin/export/combined') {
  $token = bearer_token(); $adm = $_ENV['ADMIN_TOKEN'] ?? '';
  if (!$token || !$adm || !hash_equals($adm, $token)) { json(['error' => 'unauthorized'], 401); }

  $pdo     = db();
  $zone_id = isset($_GET['zone_id']) ? trim((string)$_GET['zone_id']) : null; // numeric ID
  $from    = $_GET['from'] ?? null;   // YYYY-MM-DD inclusive
  $to      = $_GET['to']   ?? null;   // YYYY-MM-DD exclusive
  $limit   = (int)($_GET['limit'] ?? 10000);
  if ($limit < 1) $limit = 1; if ($limit > 100000) $limit = 100000;

  $conds = []; $params = [];

  if ($zone_id !== null && $zone_id !== '') {
    if (!ctype_digit($zone_id) || (int)$zone_id < 1) { json(['error'=>'bad_zone_id'], 422); }
    $conds[]   = 'tu.zone_id = ?';
    $params[]  = (int)$zone_id;
  }
  if ($from) { $conds[] = 'ts >= ?'; $params[] = $from; }
  if ($to)   { $conds[] = 'ts < ?';  $params[] = $to;   }

  // Apply the same conditions to clicks/impressions with table alias swap on ts
  $wc = $conds ? 'WHERE '.implode(' AND ', array_map(fn($c)=>str_replace('ts','c.ts',$c), $conds)) : '';
  $wi = $conds ? 'WHERE '.implode(' AND ', array_map(fn($c)=>str_replace('ts','i.ts',$c), $conds)) : '';

  $sql = "
    (SELECT 'click' AS event_type, c.ts, tu.zone_id, c.tracking_url_id, c.ip, c.user_agent, c.referrer, c.host, c.http_method, c.dest, c.query_string
       FROM click_logs c
       LEFT JOIN tracking_urls tu ON tu.id = c.tracking_url_id
       $wc)
    UNION ALL
    (SELECT 'impression' AS event_type, i.ts, tu.zone_id, i.tracking_url_id, i.ip, i.user_agent, i.referrer, i.host, i.http_method, NULL AS dest, NULL AS query_string
       FROM impression_logs i
       LEFT JOIN tracking_urls tu ON tu.id = i.tracking_url_id
       $wi)
    ORDER BY ts DESC
    LIMIT $limit";

  $st = $pdo->prepare($sql);
  $st->execute(array_merge($params, $params));

  header('Content-Type: text/csv; charset=utf-8');
  header('Content-Disposition: attachment; filename="events_'.date('Ymd_His').'.csv"');
  $out = fopen('php://output','w');
  fputcsv($out, ['event_type','ts','zone_id','tracking_url_id','ip','user_agent','referrer','host','http_method','dest','query_string']);
  while ($row = $st->fetch(PDO::FETCH_NUM)) { fputcsv($out, $row); }
  fclose($out); exit;
}




  // GET /admin/partners
  if ($method === 'GET' && preg_match("#^/admin/partners$#", $uri)) {
    $pdo = db();
    $status = $_GET["status"] ?? null; // active|inactive
    $search = $_GET["search"] ?? null; // substring in name
    $letter = $_GET["letter"] ?? null; // first-letter filter (A-Z)
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 100;
    if ($limit < 1) $limit = 1; if ($limit > 1000) $limit = 1000;

    $conds = ["deleted_at IS NULL"]; $params = [];
    if ($status && in_array($status, ["active","inactive"], true)) { $conds[] = "status = ?"; $params[] = $status; }
    if ($search !== null && $search !== "") { $conds[] = "name LIKE ?"; $params[] = "%".$search."%"; }
    if ($letter !== null && $letter !== "" && ctype_alpha($letter)) { $conds[] = "name LIKE ?"; $params[] = strtoupper($letter)."%"; }
    $where = $conds ? ("WHERE ".implode(" AND ", $conds)) : "";

    $orderby = ($letter !== null && $letter !== "") ? "ORDER BY name ASC" : "ORDER BY id DESC";
    $sql = "SELECT id, name, status, created_at, updated_at FROM partners $where $orderby LIMIT $limit";
    $st = $pdo->prepare($sql); $st->execute($params);
    $rows = $st->fetchAll(PDO::FETCH_ASSOC);
    json(["count"=>count($rows), "items"=>$rows]);
  }


  // GET /admin/advertisers
  if ($method === 'GET' && preg_match("#^/admin/advertisers$#", $uri)) {
    $pdo = db();
    $search = $_GET["search"] ?? null; // substring in name
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 100;
    if ($limit < 1) $limit = 1; if ($limit > 1000) $limit = 1000;

    $conds = ["deleted_at IS NULL"]; $params = [];
    if ($search !== null && $search !== "") { $conds[] = "name LIKE ?"; $params[] = "%".$search."%"; }
    $where = $conds ? ("WHERE ".implode(" AND ", $conds)) : "";

    // minimal, safe column set (expand later if needed)
    $sql = "SELECT id, name, created_at, updated_at FROM advertisers $where ORDER BY id DESC LIMIT $limit";
    $st = $pdo->prepare($sql); $st->execute($params);
    $rows = $st->fetchAll(PDO::FETCH_ASSOC);
    json(["count"=>count($rows), "items"=>$rows]);
  }

  // POST /admin/advertisers (create)
  if ($method === 'POST' && $uri === '/admin/advertisers') {
    require_session_auth(['admin', 'superadmin']);
    header('x-adv-route: hit');
    header('Content-Type: application/json; charset=utf-8');

    $in   = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;
    $name = trim((string)($in['name'] ?? ''));

    if ($name === '') { json(['error'=>'name_required'], 422); }

    $pdo = db();
    try {
      $st = $pdo->prepare("INSERT INTO advertisers (name, created_at, updated_at) VALUES (:n, NOW(), NOW())");
      $st->execute([':n' => $name]);
      $id = (int)$pdo->lastInsertId();
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') {
        // Likely duplicate unique name; return 409 with existing row
        $q = $pdo->prepare("SELECT id,name,created_at,updated_at FROM advertisers WHERE name = ? LIMIT 1");
        $q->execute([$name]);
        $existing = $q->fetch(PDO::FETCH_ASSOC) ?: null;
        json(['error'=>'duplicate_name', 'advertiser'=>$existing], 409);
      }
      throw $e;
    }

    $get = $pdo->prepare("SELECT id,name,created_at,updated_at FROM advertisers WHERE id = ? LIMIT 1");
    $get->execute([$id]);
    $row = $get->fetch(PDO::FETCH_ASSOC) ?: null;
    if (!$row) { json(['error'=>'save_failed'], 500); }

    http_response_code(201);
    json(['ok'=>true, 'created'=>true, 'advertiser'=>$row]);
  }


// PUT /admin/advertisers/{id} (update name)
if ($method === 'PUT' && preg_match("#^/admin/advertisers/([0-9]+)$#", $uri, $mm)) {
  require_session_auth(['admin', 'superadmin']);
  header('x-adv-route: hit');
  header('Content-Type: application/json; charset=utf-8');

  $id = (int)$mm[1];
  if ($id <= 0) { json(['error'=>'bad_id'], 422); }

  $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;
  $name = array_key_exists('name', $in) ? trim((string)$in['name']) : null;

  if ($name === '')   { json(['error'=>'name_required'], 422); }
  if ($name === null) { json(['error'=>'nothing_to_update'], 422); }

  $pdo = db();
  try {
    $st = $pdo->prepare('UPDATE advertisers SET name = :n, updated_at = NOW() WHERE id = :id');
    $st->execute([':n'=>$name, ':id'=>$id]);
  } catch (PDOException $e) {
    if ($e->getCode() === '23000') {
      // Likely unique constraint on name
      $q = $pdo->prepare('SELECT id,name,created_at,updated_at FROM advertisers WHERE name = ? LIMIT 1');
      $q->execute([$name]);
      $existing = $q->fetch(PDO::FETCH_ASSOC) ?: null;
      json(['error'=>'duplicate_name', 'advertiser'=>$existing], 409);
    }
    throw $e;
  }

  $get = $pdo->prepare('SELECT id,name,created_at,updated_at FROM advertisers WHERE id = ? LIMIT 1');
  $get->execute([$id]);
  $row = $get->fetch(PDO::FETCH_ASSOC);
  if (!$row) { json(['error'=>'not_found'], 404); }

  json(['ok'=>true, 'id'=>$id, 'advertiser'=>$row]);
}

// DELETE /admin/advertisers/{id}
if ($method === 'DELETE' && preg_match("#^/admin/advertisers/([0-9]+)$#", $uri, $mm)) {
  require_session_auth(['admin', 'superadmin']);
  header('x-adv-route: hit');
  header('Content-Type: application/json; charset=utf-8');

  $id = (int)$mm[1];
  if ($id <= 0) { json(['error'=>'bad_id'], 422); }

  $pdo = db();

  // Soft-delete: set deleted_at timestamp
  $del = $pdo->prepare('UPDATE advertisers SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL');
  $del->execute([$id]);

  if ($del->rowCount() === 0) { json(['error'=>'not_found'], 404); }

  json(['ok'=>true, 'id'=>$id]);
}





    // POST /admin/partners (create)
    if ($method === 'POST' && $uri === '/admin/partners') {
      require_session_auth(['admin', 'superadmin']);
      header('x-partners-route: hit');
      header('Content-Type: application/json; charset=utf-8');

      $in     = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;
      $name   = trim((string)($in['name'] ?? ''));
      $status = (string)($in['status'] ?? 'active');

      if ($name === '') { json(['error'=>'name_required'], 422); }
      if (!in_array($status, ['active','inactive'], true)) { json(['error'=>'bad_status'], 422); }

      $pdo = db();
      try {
        $st = $pdo->prepare("INSERT INTO partners (name,status,created_at,updated_at) VALUES (:n,:s,NOW(),NOW())");
        $st->execute([':n'=>$name, ':s'=>$status]);
        $id = (int)$pdo->lastInsertId();
      } catch (PDOException $e) {
        if ($e->getCode() === '23000') {
          // likely duplicate (unique name). return 409 with the existing row for clarity
          $q = $pdo->prepare("SELECT id,name,status,created_at,updated_at FROM partners WHERE name = ? LIMIT 1");
          $q->execute([$name]);
          $existing = $q->fetch(PDO::FETCH_ASSOC) ?: null;
          json(['error'=>'duplicate_name','partner'=>$existing], 409);
        }
        throw $e;
      }

      $get = $pdo->prepare("SELECT id,name,status,created_at,updated_at FROM partners WHERE id = ? LIMIT 1");
      $get->execute([$id]);
      $row = $get->fetch(PDO::FETCH_ASSOC) ?: null;
      if (!$row) { json(['error'=>'save_failed'], 500); }

      http_response_code(201);
      json(['ok'=>true, 'created'=>true, 'partner'=>$row]);
    }

  // DELETE /admin/partners/{id}
  if ($method === 'DELETE' && preg_match("#^/admin/partners/([0-9]+)$#", $uri, $mm)) {
    require_session_auth(['admin', 'superadmin']);
    header('x-partners-route: hit');
    header('Content-Type: application/json; charset=utf-8');

    $id = (int)$mm[1];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }

    $pdo = db();

    // Guard: block delete if partner still has zones (including soft-deleted zones)
    $chk = $pdo->prepare("SELECT COUNT(*) FROM partner_zones WHERE partner_id = ?");
    $chk->execute([$id]);
    $zones = (int)$chk->fetchColumn();
    if ($zones > 0) { json(['error'=>'has_zones','count'=>$zones], 409); }

    // Soft-delete: set deleted_at timestamp
    $del = $pdo->prepare("UPDATE partners SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
    $del->execute([$id]);

    if ($del->rowCount() === 0) { json(['error'=>'not_found'], 404); }

    json(['ok'=>true,'id'=>$id]);
  }


  // GET /admin/zones (list all zones, optionally filtered by partner_id)
  if ($method === "GET" && $uri === '/admin/zones') {
    require_session_auth(['admin', 'superadmin']);
    $pdo = db();
    $partnerId = isset($_GET["partner_id"]) ? (int)$_GET["partner_id"] : null;
    $status = $_GET["status"] ?? null; // active|inactive
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 200;
    if ($limit < 1) $limit = 1; if ($limit > 2000) $limit = 2000;

    $conds = ["deleted_at IS NULL"]; $params = [];
    if ($partnerId > 0) { $conds[] = "partner_id = ?"; $params[] = $partnerId; }
    if ($status && in_array($status, ["active","inactive"], true)) { $conds[] = "status = ?"; $params[] = $status; }
    $where = count($conds) > 0 ? "WHERE ".implode(" AND ", $conds) : "";
    $sql = "SELECT id, partner_id, name, type, width, height, rotation_seconds, status, open_new_window, created_at, updated_at FROM partner_zones $where ORDER BY id DESC LIMIT $limit";
    $st = count($params) > 0 ? $pdo->prepare($sql) : $pdo->query($sql);
    if (count($params) > 0) $st->execute($params);
    $rows = $st->fetchAll(PDO::FETCH_ASSOC);
    json(["ok"=>true, "count"=>count($rows), "zones"=>$rows]);
  }

  // GET /admin/partners/{id}/zones
  if ($method === "GET" && preg_match("#^/admin/partners/([0-9]+)/zones$#", $uri, $mm)) {
    $partnerId = (int)$mm[1];
    if ($partnerId <= 0) { json(["error"=>"bad_partner_id"], 422); }
    $pdo = db();
    $status = $_GET["status"] ?? null; // active|inactive
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 200;
    if ($limit < 1) $limit = 1; if ($limit > 2000) $limit = 2000;

    $conds = ["partner_id = ?", "deleted_at IS NULL"]; $params = [$partnerId];
    if ($status && in_array($status, ["active","inactive"], true)) { $conds[] = "status = ?"; $params[] = $status; }
    $where = "WHERE ".implode(" AND ", $conds);
    $sql = "SELECT id, partner_id, name, type, width, height, rotation_seconds, status, open_new_window, created_at, updated_at FROM partner_zones $where ORDER BY id DESC LIMIT $limit";
    $st = $pdo->prepare($sql); $st->execute($params);
    $rows = $st->fetchAll(PDO::FETCH_ASSOC);
    json(["count"=>count($rows), "items"=>$rows, "partner_id"=>$partnerId]);
  }

// PUT /admin/partner-zones/{id}
if ($method === 'PUT' && preg_match("#^/admin/partner-zones/([0-9]+)$#", $uri, $mm)) {
  require_session_auth(['admin', 'superadmin']);
  header('x-zones-route: hit');
  header('Content-Type: application/json; charset=utf-8');

  $id = (int)$mm[1];
  if ($id <= 0) { json(['error'=>'bad_id'], 422); }

  $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;
  $name   = array_key_exists('name', $in)   ? trim((string)$in['name'])   : null;
  $status = array_key_exists('status', $in) ? (string)$in['status']       : null;
  $type = array_key_exists('type', $in) ? (string)$in['type'] : null;
  $rot  = array_key_exists('rotation_seconds', $in) ? (int)$in['rotation_seconds'] : null;
  $open_new_window = array_key_exists('open_new_window', $in) ? (int)$in['open_new_window'] : null;
    // Optional size: width/height (>=1..4096). 0 or null clears the value.
    $width  = array_key_exists('width',  $in) ? (int)$in['width']  : null;
    $height = array_key_exists('height', $in) ? (int)$in['height'] : null;

    if ($width !== null && $width !== 0 && ($width < 1 || $width > 4096))   { json(['error'=>'bad_width'], 422); }
    if ($height !== null && $height !== 0 && ($height < 1 || $height > 4096)) { json(['error'=>'bad_height'], 422); }

    // Normalize: 0 → NULL
    if ($width  === 0)  { $width = null; }
    if ($height === 0)  { $height = null; }

  $pdo = db();
  $cur = $pdo->prepare('SELECT type FROM partner_zones WHERE id = ? LIMIT 1');
  $cur->execute([$id]);
  $curRow = $cur->fetch(PDO::FETCH_ASSOC);
  if (!$curRow) { json(['error'=>'not_found'], 404); }

  $effectiveType = ($type !== null) ? $type : (string)($curRow['type'] ?? 'website');
  if ($effectiveType === 'pixel') {
    $width = 1;
    $height = 1;
    $rot = 0;
  }

  if ($type !== null && !in_array($type, ['website','email','pixel'], true)) { json(['error'=>'bad_type'], 422); }
  if ($rot !== null && ($rot < 0 || $rot > 86400)) { json(['error'=>'bad_rotation_seconds'], 422); }
  if ($name === '') { json(['error'=>'name_required'], 422); }
  if ($status !== null && !in_array($status, ['active','inactive'], true)) { json(['error'=>'bad_status'], 422); }

  $fields = []; $params = [':id'=>$id];
  if ($name !== null)   { $fields[] = 'name = :n';   $params[':n'] = $name; }
  if ($status !== null) { $fields[] = 'status = :s'; $params[':s'] = $status; }
  if ($width  !== null) { $fields[] = 'width = :w';   $params[':w'] = $width; }
  if ($height !== null) { $fields[] = 'height = :h';  $params[':h'] = $height; }
  if ($type !== null) { $fields[] = 'type = :t'; $params[':t'] = $type; }
  if ($rot  !== null) { $fields[] = 'rotation_seconds = :r'; $params[':r'] = $rot; }
  if ($open_new_window !== null) { $fields[] = 'open_new_window = :onw'; $params[':onw'] = $open_new_window; }


  if (!$fields) { json(['error'=>'nothing_to_update'], 422); }

  $sql = 'UPDATE partner_zones SET '.implode(', ', $fields).', updated_at = NOW() WHERE id = :id';
  try {
    $st = $pdo->prepare($sql); $st->execute($params);
  } catch (PDOException $e) {
    if ($e->getCode() === '23000') { json(['error'=>'duplicate_zone'], 409); }
    throw $e;
  }

$get = $pdo->prepare('SELECT id,partner_id,name,type,width,height,rotation_seconds,status,open_new_window,created_at,updated_at FROM partner_zones WHERE id = ? LIMIT 1');
  $get->execute([$id]);
  $row = $get->fetch(PDO::FETCH_ASSOC);
  if (!$row) { json(['error'=>'not_found'], 404); }

  json(['ok'=>true, 'id'=>$id, 'zone'=>$row]);
}

// DELETE /admin/partner-zones/{id}
if ($method === 'DELETE' && preg_match("#^/admin/partner-zones/([0-9]+)$#", $uri, $mm)) {
  require_session_auth(['admin', 'superadmin']);
  header('x-zones-route: hit');
  header('Content-Type: application/json; charset=utf-8');

  $id = (int)$mm[1];
  if ($id <= 0) { json(['error'=>'bad_id'], 422); }

  $pdo = db();
  
  // Soft-delete: set deleted_at timestamp
  $del = $pdo->prepare("UPDATE partner_zones SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
  $del->execute([$id]);

  if ($del->rowCount() === 0) { json(['error'=>'not_found'], 404); }

  json(['ok'=>true, 'id'=>$id]);
}


// POST /admin/partners/{id}/zones (create a zone)
if ($method === 'POST' && preg_match("#^/admin/partners/([0-9]+)/zones$#", $uri, $mm)) {
  require_session_auth(['admin', 'superadmin']);
  header('x-partner-zones-route: create');
  header('Content-Type: application/json; charset=utf-8');

  $partnerId = (int)$mm[1];
  if ($partnerId <= 0) { json(['error'=>'bad_partner_id'], 422); }

  $pdo = db();

  // ensure partner exists
  $chk = $pdo->prepare("SELECT id FROM partners WHERE id = ? LIMIT 1");
  $chk->execute([$partnerId]);
  if (!$chk->fetch()) { json(['error'=>'partner_not_found'], 404); }

  // input (JSON body preferred; POST form also supported)
  $in = json_decode(file_get_contents('php://input') ?: '[]', true);
  if (!is_array($in) || !$in) { $in = $_POST; }

  $name   = trim((string)($in['name']  ?? ''));
  $type   = (string)($in['type'] ?? 'website');               // 'website' | 'email' | 'pixel'
  $rotSec = isset($in['rotation_seconds'])
  ? (int)$in['rotation_seconds']
  : ($type === 'website' ? 10 : 0);  // website default 10, email/pixel default 0

  $status = (string)($in['status'] ?? 'active');              // 'active' | 'inactive'
  $open_new_window = isset($in['open_new_window']) ? (int)$in['open_new_window'] : 0;
    // Optional dimensions; 0 or missing → NULL
    $width  = isset($in['width'])  ? (int)$in['width']  : null;
    $height = isset($in['height']) ? (int)$in['height'] : null;

    if ($type === 'pixel') {
      $width = 1;
      $height = 1;
      $rotSec = 0;
    }

    if ($width !== null && $width !== 0 && ($width < 1 || $width > 4096))   { json(['error'=>'bad_width'], 422); }
    if ($height !== null && $height !== 0 && ($height < 1 || $height > 4096)) { json(['error'=>'bad_height'], 422); }

    if ($width  === 0) { $width  = null; }
    if ($height === 0) { $height = null; }

  // validations
  if ($name === '') { json(['error'=>'name_required'], 422); }
  // allow 0..86400; if non-email, 0 is allowed but means "no rotation"
  if ($rotSec < 0 || $rotSec > 86400) { json(['error'=>'bad_rotation_seconds'], 422); }
  if (!in_array($type, ['website','email','pixel'], true)) { json(['error'=>'bad_type'], 422); }
  if (!in_array($status, ['active','inactive'], true)) { json(['error'=>'bad_status'], 422); }

  try {

$ins = $pdo->prepare("
  INSERT INTO partner_zones (partner_id, name, type, width, height, rotation_seconds, status, open_new_window, created_at, updated_at)
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$ins->execute([$partnerId, $name, $type, $width, $height, $rotSec, $status, $open_new_window]);



    $id  = (int)$pdo->lastInsertId();
      $get = $pdo->prepare("
        SELECT id, partner_id, name, type, width, height, rotation_seconds, status, open_new_window, created_at, updated_at
        FROM partner_zones
        WHERE id = ? LIMIT 1
      ");
    $get->execute([$id]);
    $row = $get->fetch(PDO::FETCH_ASSOC);

      // Auto-create a tracking URL for pixel zones so impressions can be attributed cleanly
      if ($row && ($row['type'] ?? '') === 'pixel') {
        try {
          // Avoid duplicate pixel tracking URLs for the same zone
          $chk = $pdo->prepare('SELECT id FROM tracking_urls WHERE zone_id = ? AND ad_item_id IS NULL LIMIT 1');
          $chk->execute([$id]);
          $existing = $chk->fetch(PDO::FETCH_ASSOC);

          if (!$existing) {
            $insT = $pdo->prepare("
              INSERT INTO tracking_urls (
                dest_url,
                zone_id,
                utm_source,
                utm_medium,
                utm_campaign,
                utm_content,
                utm_policy,
                created_at
              ) VALUES (
                :dest_url,
                :zone_id,
                :utm_source,
                :utm_medium,
                :utm_campaign,
                :utm_content,
                :utm_policy,
                NOW()
              )
            ");

            $insT->execute([
              ':dest_url'    => '#',                      // pixel has no click target
              ':zone_id'     => $id,
              ':utm_source'  => 'zone_pixel',
              ':utm_medium'  => 'email',
              ':utm_campaign'=> null,
              ':utm_content' => 'zone_'.$id.'_pixel',
              ':utm_policy'  => 'ignore',
            ]);
          }
        } catch (Throwable $_) {
          // Do not fail zone creation if tracking URL insert has issues
        }
      }


    http_response_code(201);
    json(['ok'=>true, 'created'=>true, 'zone'=>$row]);
  } catch (PDOException $e) {
    // Duplicate or FK violations return SQLSTATE 23000
    if ($e->getCode() === '23000') {
      json(['error'=>'conflict','sqlstate'=>'23000'], 409);
    }
    throw $e;
  }
}







  // PUT /admin/partners/{id} (update name/status)
  if ($method === 'PUT' && preg_match("#^/admin/partners/([0-9]+)$#", $uri, $mm)) {
    require_session_auth(['admin', 'superadmin']);
    header('x-partners-route: hit');
    header('Content-Type: application/json; charset=utf-8');

    $id = (int)$mm[1];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }

    $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;
    $name   = array_key_exists('name', $in)   ? trim((string)$in['name'])   : null;
    $status = array_key_exists('status', $in) ? (string)$in['status']       : null;

    if ($name !== null && $name === '') { json(['error'=>'name_required'], 422); }
    if ($status !== null && !in_array($status, ['active','inactive'], true)) { json(['error'=>'bad_status'], 422); }

    $fields = []; $params = [':id' => $id];
    if ($name !== null)   { $fields[] = 'name = :n';   $params[':n'] = $name; }
    if ($status !== null) { $fields[] = 'status = :s'; $params[':s'] = $status; }
    if (!$fields) { json(['error'=>'nothing_to_update'], 422); }

    $sql = 'UPDATE partners SET '.implode(', ', $fields).', updated_at = NOW() WHERE id = :id';
    $pdo = db();
    $st = $pdo->prepare($sql); $st->execute($params);

    $get = $pdo->prepare('SELECT id,name,status,created_at,updated_at FROM partners WHERE id = ? LIMIT 1');
    $get->execute([$id]);
    $row = $get->fetch(PDO::FETCH_ASSOC);
    if (!$row) { json(['error'=>'not_found'], 404); }

    json(['ok'=>true, 'id'=>$id, 'partner'=>$row]);
  }






  // CSV: /admin/export/partners
  if ($method === "GET" && $uri === "/admin/export/partners") {
    $pdo = db();
    $status = $_GET["status"] ?? null;
    $search = $_GET["search"] ?? null;
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 10000;
    if ($limit < 1) $limit = 1; if ($limit > 100000) $limit = 100000;

    $conds = []; $params = [];
    if ($status && in_array($status, ["active","inactive"], true)) { $conds[]="status = ?"; $params[]=$status; }
    if ($search !== null && $search !== "") { $conds[]="name LIKE ?"; $params[]="%".$search."%"; }
    $where = $conds ? ("WHERE ".implode(" AND ", $conds)) : "";

    $sql = "SELECT id, name, status, created_at, updated_at FROM partners $where ORDER BY id DESC LIMIT $limit";
    $st = $pdo->prepare($sql); $st->execute($params);

    header("Content-Type: text/csv; charset=utf-8");
    header("Content-Disposition: attachment; filename=partners_".date("Ymd_His").".csv");
    $out = fopen("php://output","w");
    fputcsv($out, ["id","name","status","created_at","updated_at"]);
    while ($row = $st->fetch(PDO::FETCH_NUM)) { fputcsv($out, $row); }
    fclose($out); exit;
  }


    // CSV: /admin/export/partner-zones
    if ($method === "GET" && $uri === "/admin/export/partner-zones") {
      $pdo = db();
      $partnerId = isset($_GET["partner_id"]) ? (int)$_GET["partner_id"] : 0;
      $status = $_GET["status"] ?? null;
      $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 10000;
      if ($limit < 1) $limit = 1; if ($limit > 100000) $limit = 100000;

      $conds = []; $params = [];
      if ($partnerId > 0) { $conds[]="partner_id = ?"; $params[]=$partnerId; }
      if ($status && in_array($status, ["active","inactive"], true)) { $conds[]="status = ?"; $params[]=$status; }
      $where = $conds ? ("WHERE ".implode(" AND ", $conds)) : "";

      $sql = "SELECT id, partner_id, name, type, width, height, rotation_seconds, status, created_at, updated_at
              FROM partner_zones $where
              ORDER BY id DESC
              LIMIT $limit";
      $st = $pdo->prepare($sql); $st->execute($params);

      header("Content-Type: text/csv; charset=utf-8");
      header("Content-Disposition: attachment; filename=partner_zones_".date("Ymd_His").".csv");
      $out = fopen("php://output","w");
      fputcsv($out, ["id","partner_id","name","type","width","height","rotation_seconds","status","created_at","updated_at"]);
      while ($row = $st->fetch(PDO::FETCH_NUM)) { fputcsv($out, $row); }
      fclose($out); exit;
    }


  // CSV: /admin/export/partners-combined
  if ($method === "GET" && $uri === "/admin/export/partners-combined") {
    $pdo = db();
    $partnerId = isset($_GET["partner_id"]) ? (int)$_GET["partner_id"] : 0;
    $status = $_GET["status"] ?? null;
    $from = $_GET["from"] ?? null; $to = $_GET["to"] ?? null;

    $zConds = []; $zParams = [];
    if ($partnerId > 0) { $zConds[] = "pz.partner_id = ?"; $zParams[] = $partnerId; }
    if ($status && in_array($status, ["active","inactive"], true)) { $zConds[] = "pz.status = ?"; $zParams[] = $status; }
    $zw = $zConds ? ("WHERE ".implode(" AND ", $zConds)) : "";

    $cConds = []; $cParams = []; $iConds = []; $iParams = [];
    if ($from) { $cConds[] = "c.ts >= ?"; $cParams[] = $from; $iConds[] = "i.ts >= ?"; $iParams[] = $from; }
    if ($to)   { $cConds[] = "c.ts <  ?"; $cParams[] = $to;   $iConds[] = "i.ts <  ?"; $iParams[] = $to;   }
    $cw = $cConds ? ("WHERE ".implode(" AND ", $cConds)) : "";
    $iw = $iConds ? ("WHERE ".implode(" AND ", $iConds)) : "";

    header("Content-Type: text/csv; charset=utf-8");
    header("Content-Disposition: attachment; filename=partners_combined_".date("Ymd_His").".csv");
    $sql = "
      WITH
      clicks_by_zone AS (
        SELECT tu.zone_id AS zone_id, COUNT(*) AS clicks
        FROM click_logs c
        LEFT JOIN tracking_urls tu ON tu.id = c.tracking_url_id
        $cw
        GROUP BY tu.zone_id
      ),
      imps_by_zone AS (
        SELECT tu.zone_id AS zone_id, COUNT(*) AS impressions
        FROM impression_logs i
        LEFT JOIN tracking_urls tu ON tu.id = i.tracking_url_id
        $iw
        GROUP BY tu.zone_id
      )
      SELECT
        p.id   AS partner_id,
        p.name AS partner_name,
        pz.id  AS zone_id,
        pz.name AS zone_name,
        COALESCE(cbz.clicks, 0)      AS clicks,
        COALESCE(ibz.impressions, 0) AS impressions
      FROM partner_zones pz
      LEFT JOIN partners p ON p.id = pz.partner_id
      LEFT JOIN clicks_by_zone cbz ON cbz.zone_id = pz.id
      LEFT JOIN imps_by_zone ibz   ON ibz.zone_id = pz.id
      $zw
      ORDER BY p.id DESC, pz.id DESC
    ";
    $st = db()->prepare($sql);
    $st->execute(array_merge($cParams, $iParams, $zParams));
    $out = fopen("php://output","w");
    fputcsv($out, ["partner_id","partner_name","zone_id","zone_name","clicks","impressions"]);
    while ($row = $st->fetch(PDO::FETCH_NUM)) { fputcsv($out, $row); }
    fclose($out); exit;
  }

  // ==== Campaigns ====

  // Allowed enums
  $CT_CAMPAIGN_STATUSES = ['draft','pending','active','paused','completed','archived'];
  $CT_UTM_POLICIES      = ['append_if_missing','override_always','ignore'];
  $CT_PACING_MODES      = ['even','asap'];

  // GET /admin/campaigns (search all campaigns)
  if ($method === 'GET' && preg_match("#^/admin/campaigns$#", $uri)) {
    $pdo = db();
    $search = $_GET["search"] ?? null;
    $limit  = isset($_GET["limit"]) ? (int)$_GET["limit"] : 100;
    if ($limit < 1) $limit = 1; if ($limit > 1000) $limit = 1000;

    $conds = []; $params = [];
    if ($search !== null && $search !== "") { 
      $conds[] = "c.name LIKE ?"; 
      $params[] = "%".$search."%"; 
    }
    // Filter out soft-deleted campaigns
    $conds[] = "c.deleted_at IS NULL";
    $where = $conds ? ("WHERE ".implode(" AND ", $conds)) : "";

    $sql = "SELECT c.id, c.advertiser_id, c.name, c.status, c.start_at, c.end_at, c.created_at, c.updated_at,
                   a.name AS advertiser_name
            FROM campaigns c
            LEFT JOIN advertisers a ON a.id = c.advertiser_id
            $where
            ORDER BY c.id DESC
            LIMIT $limit";
    $st = $pdo->prepare($sql); 
    $st->execute($params);
    $rows = $st->fetchAll(PDO::FETCH_ASSOC);
    json(["count"=>count($rows), "items"=>$rows]);
  }

  // GET /admin/advertisers/{advId}/campaigns
  if ($method === 'GET' && preg_match('#^/admin/advertisers/([0-9]+)/campaigns$#', $uri, $m)) {
    header('X-Campaigns-Route: list');
    $advId = (int)$m[1];
    if ($advId <= 0) { json(['error'=>'bad_advertiser_id'], 422); }

    $pdo = db();
    // ensure advertiser exists (nice 404)
    $chk = $pdo->prepare('SELECT id FROM advertisers WHERE id = ? LIMIT 1');
    $chk->execute([$advId]);
    if (!$chk->fetchColumn()) { json(['error'=>'advertiser_not_found'], 404); }

    $status = $_GET['status'] ?? null;
    if ($status !== null && !in_array($status, $CT_CAMPAIGN_STATUSES, true)) {
      json(['error'=>'bad_status'], 422);
    }
    $from  = $_GET['from']  ?? null;   // Y-m-d or Y-m-d H:i:s (no strict parse here)
    $to    = $_GET['to']    ?? null;   // Y-m-d or Y-m-d H:i:s
    $limit = isset($_GET['limit']) ? max(1, min(200, (int)$_GET['limit'])) : 100;
    $offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;

    $conds  = ['advertiser_id = ?'];
    $params = [$advId];
    // Filter out soft-deleted campaigns
    $conds[] = 'deleted_at IS NULL';
    if ($status !== null) { $conds[] = 'status = ?'; $params[] = $status; }
    if ($from)            { $conds[] = 'start_at IS NULL OR start_at >= ?'; $params[] = $from; }
    if ($to)              { $conds[] = 'end_at   IS NULL OR end_at   <= ?'; $params[] = $to; }
    $where = 'WHERE '.implode(' AND ', array_map(fn($c)=>"($c)", $conds));

    // total (without limit)
    $stc = $pdo->prepare("SELECT COUNT(*) c FROM campaigns $where");
    $stc->execute($params);
    $total = (int)$stc->fetchColumn();

    // page
      $sql = "SELECT id, advertiser_id, name, status, start_at, end_at, timezone,
                     priority, test_mode, created_at, updated_at
              FROM campaigns
              $where
              ORDER BY id DESC
              LIMIT $limit OFFSET $offset";
      $st = $pdo->prepare($sql);
      foreach (array_values($params) as $i=>$v) { $st->bindValue($i+1, $v, PDO::PARAM_STR); }
      $st->execute();
      $rows = $st->fetchAll(PDO::FETCH_ASSOC);

    json(['count'=>$total, 'items'=>$rows, 'limit'=>$limit, 'offset'=>$offset, 'advId'=>$advId]);
  }

  // POST /admin/advertisers/{advId}/campaigns
  if ($method === 'POST' && preg_match('#^/admin/advertisers/([0-9]+)/campaigns$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Campaigns-Route: create');
    $advId = (int)$m[1];
    if ($advId <= 0) { json(['error'=>'bad_advertiser_id'], 422); }

    $pdo = db();
    $chk = $pdo->prepare('SELECT id FROM advertisers WHERE id = ? LIMIT 1');
    $chk->execute([$advId]);
    if (!$chk->fetchColumn()) { json(['error'=>'advertiser_not_found'], 404); }

    $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: $_POST;

    $name = trim((string)($in['name'] ?? ''));
    if ($name === '') { json(['error'=>'name_required'], 422); }

    $status = (string)($in['status'] ?? 'draft');
    if (!in_array($status, $CT_CAMPAIGN_STATUSES, true)) { json(['error'=>'bad_status'], 422); }

    $tz     = (string)($in['timezone'] ?? 'UTC');
    $prio   = isset($in['priority']) ? max(0, min(255, (int)$in['priority'])) : 50;
    $notes  = array_key_exists('notes', $in) ? (string)$in['notes'] : null;

    $utm    = $in['utm_policy'] ?? null;
    if ($utm !== null && !in_array($utm, $CT_UTM_POLICIES, true)) { json(['error'=>'bad_utm_policy'], 422); }

    $dest   = array_key_exists('default_destination_url', $in) ? (string)$in['default_destination_url'] : null;
    $track  = array_key_exists('tracking_template', $in) ? (string)$in['tracking_template'] : null;

    $cap_d_i = isset($in['daily_impression_cap']) ? (int)$in['daily_impression_cap'] : null;
    $cap_t_i = isset($in['total_impression_cap']) ? (int)$in['total_impression_cap'] : null;
    $cap_d_c = isset($in['daily_click_cap']) ? (int)$in['daily_click_cap'] : null;

    $pacing = (string)($in['pacing_mode'] ?? 'even');
    if (!in_array($pacing, $CT_PACING_MODES, true)) { json(['error'=>'bad_pacing_mode'], 422); }

    $test   = !empty($in['test_mode']) ? 1 : 0;

    $start  = isset($in['start_at']) ? (string)$in['start_at'] : null;
    $end    = isset($in['end_at'])   ? (string)$in['end_at']   : null;

    $fc_i   = isset($in['frequency_cap_imps'])    ? (int)$in['frequency_cap_imps']    : null;
    $fc_s   = isset($in['frequency_cap_seconds']) ? (int)$in['frequency_cap_seconds'] : null;

    // Accept array/object or raw JSON string for daypart_json
    $daypart = null;
    if (array_key_exists('daypart_json', $in)) {
      $daypart = is_array($in['daypart_json']) || is_object($in['daypart_json'])
        ? json_encode($in['daypart_json'], JSON_UNESCAPED_SLASHES)
        : (string)$in['daypart_json'];
    }

    try {

      $sql = "INSERT INTO campaigns
                (advertiser_id, name, status, start_at, end_at, timezone, priority, notes,
                 utm_policy, default_destination_url, tracking_template,
                 daily_impression_cap, total_impression_cap, daily_click_cap,
                 pacing_mode, test_mode, daypart_json, frequency_cap_imps, frequency_cap_seconds,
                 created_at, updated_at)
              VALUES
                (:adv, :name, :status, :start_at, :end_at, :tz, :prio, :notes,
                 :utm, :dest, :tmpl,
                 :dic, :tic, :dcc,
                 :pacing, :test, :dp, :fci, :fcs,
                 NOW(), NOW())";
      $st = $pdo->prepare($sql);
      $st->execute([
        ':adv'  => $advId,
        ':name' => $name,
        ':status' => $status,
        ':start_at' => $start,
        ':end_at'   => $end,
        ':tz'    => $tz,
        ':prio'  => $prio,
        ':notes' => $notes,
        ':utm'   => $utm,
        ':dest'  => $dest,
        ':tmpl'  => $track,
        ':dic'   => $cap_d_i,
        ':tic'   => $cap_t_i,
        ':dcc'   => $cap_d_c,
        ':pacing'=> $pacing,
        ':test'  => $test,
        ':dp'    => $daypart,
        ':fci'   => $fc_i,
        ':fcs'   => $fc_s,
      ]);

      $id = (int)$pdo->lastInsertId();

      $get = $pdo->prepare("SELECT * FROM campaigns WHERE id = ? LIMIT 1");
      $get->execute([$id]);
      $row = $get->fetch(PDO::FETCH_ASSOC);

      http_response_code(201);
      json(['ok'=>true, 'created'=>true, 'campaign'=>$row]);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') { // unique (name per advertiser) or FK conflict
        json(['error'=>'conflict','sqlstate'=>'23000'], 409);
      }
      throw $e;
    }
  }

  // PUT /admin/campaigns/{id}
  if ($method === 'PUT' && preg_match('#^/admin/campaigns/([0-9]+)$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Campaigns-Route: update');
    $id = (int)$m[1];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }

    $in = json_decode(file_get_contents('php://input') ?: '[]', true) ?: [];
    $fields = [];
    $params = [':id'=>$id];

    $set = function(string $col, $val) use (&$fields,&$params) {
      $fields[] = "$col = :$col"; $params[":$col"] = $val;
    };

    if (array_key_exists('name',$in)) {
      $name = trim((string)$in['name']); if ($name==='') { json(['error'=>'name_required'], 422); }
      $set('name', $name);
    }
    if (array_key_exists('status',$in)) {
      $stt = (string)$in['status'];
      if (!in_array($stt, $CT_CAMPAIGN_STATUSES, true)) { json(['error'=>'bad_status'], 422); }
      $set('status', $stt);
    }
    if (array_key_exists('start_at',$in)) { $set('start_at', $in['start_at'] !== '' ? (string)$in['start_at'] : null); }
    if (array_key_exists('end_at',$in))   { $set('end_at',   $in['end_at']   !== '' ? (string)$in['end_at']   : null); }
    if (array_key_exists('timezone',$in)) { $set('timezone', (string)$in['timezone']); }
    if (array_key_exists('priority',$in)) { $set('priority', max(0, min(255, (int)$in['priority']))); }
    if (array_key_exists('notes',$in))    { $set('notes', (string)$in['notes']); }

    if (array_key_exists('utm_policy',$in)) {
      $utm = (string)$in['utm_policy'];
      if (!in_array($utm, $CT_UTM_POLICIES, true)) { json(['error'=>'bad_utm_policy'], 422); }
      $set('utm_policy', $utm);
    }
    if (array_key_exists('default_destination_url',$in)) { $set('default_destination_url', (string)$in['default_destination_url']); }
    if (array_key_exists('tracking_template',$in))       { $set('tracking_template', (string)$in['tracking_template']); }

    foreach (['daily_impression_cap'=>'daily_impression_cap','total_impression_cap'=>'total_impression_cap','daily_click_cap'=>'daily_click_cap','frequency_cap_imps'=>'frequency_cap_imps','frequency_cap_seconds'=>'frequency_cap_seconds'] as $inKey=>$col) {
      if (array_key_exists($inKey,$in)) {
        $v = $in[$inKey]; $set($col, ($v === '' || $v === null) ? null : (int)$v);
      }
    }

    if (array_key_exists('pacing_mode',$in)) {
      $pm = (string)$in['pacing_mode'];
      if (!in_array($pm, $CT_PACING_MODES, true)) { json(['error'=>'bad_pacing_mode'], 422); }
      $set('pacing_mode', $pm);
    }
    if (array_key_exists('test_mode',$in)) { $set('test_mode', !empty($in['test_mode']) ? 1 : 0); }

    if (array_key_exists('daypart_json',$in)) {
      $dp = is_array($in['daypart_json']) || is_object($in['daypart_json'])
        ? json_encode($in['daypart_json'], JSON_UNESCAPED_SLASHES)
        : (string)$in['daypart_json'];
      $set('daypart_json', $dp);
    }

    if (!$fields) { json(['error'=>'nothing_to_update'], 422); }
    $fields[] = 'updated_at = NOW()';

    $sql = 'UPDATE campaigns SET '.implode(', ', $fields).' WHERE id = :id';
    $pdo = db();
    try {
      $st = $pdo->prepare($sql); $st->execute($params);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') { json(['error'=>'conflict','sqlstate'=>'23000'], 409); }
      throw $e;
    }

    $get = $pdo->prepare('SELECT * FROM campaigns WHERE id = ? LIMIT 1');
    $get->execute([$id]);
    $row = $get->fetch(PDO::FETCH_ASSOC);
    if (!$row) { json(['error'=>'not_found'], 404); }
    json(['ok'=>true, 'id'=>$id, 'campaign'=>$row]);
  }

  // DELETE /admin/campaigns/{id} (soft-delete)
  if ($method === 'DELETE' && preg_match('#^/admin/campaigns/([0-9]+)$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Campaigns-Route: delete');
    $id = (int)$m[1];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }

    $pdo = db();
    
    // Soft delete: set deleted_at timestamp
    $st = $pdo->prepare('UPDATE campaigns SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL');
    $st->execute([$id]);

    if ($st->rowCount() === 0) { json(['error'=>'not_found'], 404); }
    json(['ok'=>true, 'id'=>$id, 'deleted'=>true]);
  }


    // GET /admin/campaigns/{id}/zones
    if ($method === 'GET' && preg_match('#^/admin/campaigns/([0-9]+)/zones$#', $uri, $m)) {
      header('X-Campaigns-Route: get-zones');
      $id = (int)$m[1];
      if ($id <= 0) { json(['error'=>'bad_id'], 422); }

      $pdo = db();

      // ensure campaign exists
      $chk = $pdo->prepare('SELECT id FROM campaigns WHERE id = ? LIMIT 1');
      $chk->execute([$id]);
      if (!$chk->fetchColumn()) { json(['error'=>'campaign_not_found'], 404); }

      $st = $pdo->prepare('
        SELECT
          cz.zone_id,
          cz.weight,
          cz.status AS cz_status,
          cz.created_at,
          cz.updated_at,
          pz.partner_id,
          pz.name AS zone_name,
          pz.width,
          pz.height,
          pz.open_new_window,
          p.name AS partner_name
        FROM campaign_zones cz
        LEFT JOIN partner_zones pz ON pz.id = cz.zone_id
        LEFT JOIN partners p ON p.id = pz.partner_id
        WHERE cz.campaign_id = ?
        ORDER BY cz.created_at ASC
      ');
      $st->execute([$id]);
      $items = $st->fetchAll(PDO::FETCH_ASSOC);

      json(['count' => count($items), 'items' => $items, 'campaign_id' => $id]);
    }




  // PUT /admin/campaigns/{id}/zones  (replace set)
  if ($method === 'PUT' && preg_match('#^/admin/campaigns/([0-9]+)/zones$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Campaigns-Route: set-zones');
    $id = (int)$m[1];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }

    $pdo = db();

    // ensure campaign exists
    $chk = $pdo->prepare('SELECT id FROM campaigns WHERE id = ? LIMIT 1');
    $chk->execute([$id]);
    if (!$chk->fetchColumn()) { json(['error'=>'campaign_not_found'], 404); }

    $in = json_decode(file_get_contents('php://input') ?: '[]', true);
    if (!is_array($in)) { json(['error'=>'array_required'], 422); }

    // basic validate and normalize
    $rows = [];
    $seenZoneIds = [];
    $seenDimensionKey = [];
    foreach ($in as $idx => $row) {
      if (!is_array($row)) { json(['error'=>'bad_item','index'=>$idx], 422); }
      $zid = (int)($row['zone_id'] ?? 0);
      if ($zid <= 0) { json(['error'=>'bad_zone_id','index'=>$idx], 422); }
      if (isset($seenZoneIds[$zid])) { json(['error'=>'duplicate_zone_id','zone_id'=>$zid,'index'=>$idx], 422); }
      $seenZoneIds[$zid] = true;
      $weight = isset($row['weight']) ? max(1, (int)$row['weight']) : 1;
      $status = (string)($row['status'] ?? 'active');
      if (!in_array($status, ['active','inactive'], true)) { json(['error'=>'bad_status','index'=>$idx], 422); }

      // ensure zone exists
      $zz = $pdo->prepare('SELECT id, width, height FROM partner_zones WHERE id = ? LIMIT 1');
      $zz->execute([$zid]);
      $zoneRow = $zz->fetch(PDO::FETCH_ASSOC);
      if (!$zoneRow) { json(['error'=>'zone_not_found','zone_id'=>$zid,'index'=>$idx], 422); }

      $zw = (int)($zoneRow['width'] ?? 0);
      $zh = (int)($zoneRow['height'] ?? 0);
      if ($zw > 0 && $zh > 0) {
        $dimKey = $zw . 'x' . $zh;
        if (isset($seenDimensionKey[$dimKey])) {
          json([
            'error' => 'duplicate_zone_dimensions',
            'width' => $zw,
            'height' => $zh,
            'zone_id' => $zid,
            'conflict_zone_id' => (int)$seenDimensionKey[$dimKey],
            'index' => $idx
          ], 422);
        }
        $seenDimensionKey[$dimKey] = $zid;
      }

      $rows[] = ['zone_id'=>$zid, 'weight'=>$weight, 'status'=>$status];
    }

    // replace set
    $pdo->beginTransaction();
    try {
      // Fetch existing created_at timestamps before deleting
      $existingStmt = $pdo->prepare('SELECT zone_id, created_at FROM campaign_zones WHERE campaign_id = ?');
      $existingStmt->execute([$id]);
      $existingZones = [];
      while ($row = $existingStmt->fetch(PDO::FETCH_ASSOC)) {
        $existingZones[$row['zone_id']] = $row['created_at'];
      }

      $pdo->prepare('DELETE FROM campaign_zones WHERE campaign_id = ?')->execute([$id]);
      if ($rows) {
        $ins = $pdo->prepare('INSERT INTO campaign_zones (campaign_id, zone_id, weight, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NOW())');
        foreach ($rows as $r) { 
          // Preserve original created_at if zone existed before, otherwise use NOW()
          $createdAt = $existingZones[$r['zone_id']] ?? null;
          $ins->execute([$id, $r['zone_id'], $r['weight'], $r['status'], $createdAt ?: date('Y-m-d H:i:s')]); 
        }
      }
      $pdo->commit();
    } catch (Throwable $e) {
      $pdo->rollBack();
      throw $e;
    }

    json(['ok'=>true, 'campaign_id'=>$id, 'zones_count'=>count($rows)]);
  }



// GET /admin/zones/{zid}/campaigns  (list campaigns + advertisers attached to a zone)
if ($method === 'GET' && preg_match('#^/admin/zones/([0-9]+)/campaigns$#', $uri, $m)) {
  header('X-Zones-Route: campaigns-for-zone');
  try {
    $zid = (int)$m[1];
    if ($zid <= 0) { json(['error'=>'bad_zone_id'], 422); }

    $pdo = db();

    // optional: ensure zone exists (kept lenient; return empty if not)
    $chk = $pdo->prepare('SELECT id FROM partner_zones WHERE id = ? LIMIT 1');
    $chk->execute([$zid]);
    if (!$chk->fetchColumn()) {
      // zone not found → 404 (clearer for UI)
      json(['error'=>'zone_not_found','zone_id'=>$zid], 404);
    }

    $limit = (int)($_GET['limit'] ?? 250);
    if ($limit < 1)   $limit = 1;
    if ($limit > 500) $limit = 500;

    $st = $pdo->prepare("
        SELECT
          cz.zone_id,
          cz.weight,
          cz.status       AS cz_status,
          c.id            AS campaign_id,
          c.name          AS campaign_name,
          c.status        AS campaign_status,
          c.priority      AS campaign_weight,
          c.start_at,
          c.end_at,
          c.created_at,
          c.updated_at,
          a.id            AS advertiser_id,
          a.name          AS advertiser_name
        FROM campaign_zones cz
        JOIN campaigns c      ON c.id = cz.campaign_id
        JOIN advertisers a    ON a.id = c.advertiser_id
        WHERE cz.zone_id = ?
        ORDER BY a.name ASC, c.name ASC
        LIMIT $limit
    ");
    $st->execute([$zid]);
    $items = $st->fetchAll(PDO::FETCH_ASSOC);

    json([
      'zone_id' => $zid,
      'count'   => count($items),
      'items'   => $items
    ]);
  } catch (Throwable $e) {
    error_log("Zone ad-items list error: " . $e->getMessage());
    json(['error'=>'server_error'], 500);
  }
}


  // ---------- Ad Items (modern-only, with try/catch) ----------
  // GET /admin/campaigns/{cid}/ad-items
  if ($method === 'GET' && preg_match('#^/admin/campaigns/([0-9]+)/ad-items$#', $uri, $m)) {
    header('X-Ad-Items-Route: list');
    try {
      $cid   = (int)$m[1];
      $limit = (int)($_GET['limit'] ?? 25);
      if ($limit < 1)   $limit = 1;
      if ($limit > 500) $limit = 500;

      $pdo = db();
      $st = $pdo->prepare("
        SELECT id, campaign_id, name, type, status, weight, target,
               width, height, start_at, end_at,
               asset_path, asset_mime, asset_size,
               created_at, updated_at
        FROM ad_items
        WHERE campaign_id = ? AND deleted_at IS NULL
        ORDER BY updated_at DESC, id DESC
        LIMIT $limit
      ");
      $st->execute([$cid]);
      $items = $st->fetchAll(PDO::FETCH_ASSOC);
      json(['cid'=>$cid, 'limit'=>$limit, 'count'=>count($items), 'items'=>$items]);
    } catch (Throwable $e) {
      error_log("Campaign ad-items list error: " . $e->getMessage());
      json(['error'=>'server_error'], 500);
    }
  }

  // POST /admin/campaigns/{cid}/ad-items
  if ($method === 'POST' && preg_match('#^/admin/campaigns/([0-9]+)/ad-items$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Ad-Items-Route: create');
    try {
      $cid = (int)$m[1];
      if ($cid <= 0) { json(['error'=>'bad_campaign_id'], 422); }

      $in = json_decode(file_get_contents('php://input') ?: '{}', true);
      if (!is_array($in)) $in = [];

      $name   = trim((string)($in['name']   ?? ''));
      $type   = trim((string)($in['type']   ?? 'image'));
      $status = trim((string)($in['status'] ?? 'draft'));
      $weight = (int)($in['weight'] ?? 50);
      $target = isset($in['target']) ? trim((string)$in['target']) : null;

      $width    = array_key_exists('width',$in)    ? (int)$in['width']    : null;
      $height   = array_key_exists('height',$in)   ? (int)$in['height']   : null;
      $start_at = array_key_exists('start_at',$in) ? trim((string)$in['start_at']) : null;
      $end_at   = array_key_exists('end_at',$in)   ? trim((string)$in['end_at'])   : null;

      $asset_path = array_key_exists('asset_path',$in) ? trim((string)$in['asset_path']) : null;
      $asset_mime = array_key_exists('asset_mime',$in) ? trim((string)$in['asset_mime']) : null;
      $asset_size = array_key_exists('asset_size',$in) ? (int)$in['asset_size']          : null;

      if ($name === '') { json(['error'=>'name_required'], 422); }
      if ($weight < 0) $weight = 0; if ($weight > 255) $weight = 255;

      $pdo = db();




      $st = $pdo->prepare("
        INSERT INTO ad_items
          (campaign_id, name, type, status, weight, target,
           width, height, start_at, end_at,
           asset_path, asset_mime, asset_size,
           created_at, updated_at)
        VALUES
          (:cid, :name, :type, :status, :weight, :target,
           :width, :height, :start_at, :end_at,
           :asset_path, :asset_mime, :asset_size,
           NOW(), NOW())
      ");
      $st->execute([
        ':cid'        => $cid,
        ':name'       => $name,
        ':type'       => $type,
        ':status'     => $status,
        ':weight'     => $weight,
        ':target'     => $target,
        ':width'      => $width,
        ':height'     => $height,
        ':start_at'   => $start_at,
        ':end_at'     => $end_at,
        ':asset_path' => $asset_path,
        ':asset_mime' => $asset_mime,
        ':asset_size' => $asset_size,
      ]);

      $id = (int)$pdo->lastInsertId();
      $get = $pdo->prepare("
        SELECT id, campaign_id, name, type, status, weight, target,
               width, height, start_at, end_at,
               asset_path, asset_mime, asset_size,
               created_at, updated_at
        FROM ad_items WHERE id = ? LIMIT 1
      ");
      $get->execute([$id]);
      $row = $get->fetch(PDO::FETCH_ASSOC);

      http_response_code(201);
      json(['ok'=>true, 'created'=>true, 'campaign_id'=>$cid, 'ad_item'=>$row]);
    } catch (Throwable $e) {
      error_log("Ad item creation error: " . $e->getMessage());
      json(['error'=>'server_error'], 500);
    }
  }

  // PUT /admin/ad-items/{id}
  if ($method === 'PUT' && preg_match('#^/admin/ad-items/([0-9]+)$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Ad-Items-Route: update');
    try {
      $id = (int)$m[1];
      if ($id <= 0) { json(['error'=>'bad_id'], 422); }

      $in = json_decode(file_get_contents('php://input') ?: '{}', true);
      if (!is_array($in)) $in = [];

      $nn = static function($k) use ($in) {
        return array_key_exists($k,$in)
          ? (strlen(trim((string)$in[$k])) ? trim((string)$in[$k]) : null)
          : null;
      };

      $pdo = db();

        // Look up current values so we can evaluate the post-update state
        $cur = $pdo->prepare("SELECT campaign_id, status, width, height FROM ad_items WHERE id = ? LIMIT 1");
        $cur->execute([$id]);
        $curRow = $cur->fetch(PDO::FETCH_ASSOC);
        if (!$curRow) {
          json(['error'=>'not_found'], 404);
        }

        $curStatus = (string)($curRow['status'] ?? '');
        $curWidth  = array_key_exists('width', $curRow)  ? (int)$curRow['width']  : null;
        $curHeight = array_key_exists('height', $curRow) ? (int)$curRow['height'] : null;
        $cid       = (int)($curRow['campaign_id'] ?? 0);

        // Compute effective new values (after applying this update)
        $newStatus = array_key_exists('status',$in)
          ? trim((string)$in['status'])
          : $curStatus;

        $newWidth = array_key_exists('width',$in)
          ? (int)$in['width']
          : $curWidth;

        $newHeight = array_key_exists('height',$in)
          ? (int)$in['height']
          : $curHeight;

        // Note: allow multiple active ad items with the same size in a campaign.








      $st = $pdo->prepare("
        UPDATE ad_items SET
          name       = COALESCE(:name, name),
          type       = COALESCE(:type, type),
          status     = COALESCE(:status, status),
          weight     = COALESCE(:weight, weight),
          target     = COALESCE(:target, target),
          width      = COALESCE(:width, width),
          height     = COALESCE(:height, height),
          start_at   = COALESCE(:start_at, start_at),
          end_at     = COALESCE(:end_at, end_at),
          asset_path = COALESCE(:asset_path, asset_path),
          asset_mime = COALESCE(:asset_mime, asset_mime),
          asset_size = COALESCE(:asset_size, asset_size),
          updated_at = NOW()
        WHERE id = :id
      ");
      $st->execute([
        ':id'         => $id,
        ':name'       => $nn('name'),
        ':type'       => $nn('type'),
        ':status'     => $nn('status'),
        ':weight'     => array_key_exists('weight',$in) ? (int)$in['weight'] : null,
        ':target'     => $nn('target'),
        ':width'      => array_key_exists('width',$in)  ? (int)$in['width']  : null,
        ':height'     => array_key_exists('height',$in) ? (int)$in['height'] : null,
        ':start_at'   => $nn('start_at'),
        ':end_at'     => $nn('end_at'),
        ':asset_path' => $nn('asset_path'),
        ':asset_mime' => $nn('asset_mime'),
        ':asset_size' => array_key_exists('asset_size',$in) ? (int)$in['asset_size'] : null,
      ]);

      $get = $pdo->prepare("
        SELECT id, campaign_id, name, type, status, weight, target,
               width, height, start_at, end_at,
               asset_path, asset_mime, asset_size,
               created_at, updated_at
        FROM ad_items WHERE id = ? LIMIT 1
      ");
      $get->execute([$id]);
      $row = $get->fetch(PDO::FETCH_ASSOC);
      if (!$row) { json(['error'=>'not_found'], 404); }

      json(['ok'=>true, 'id'=>$id, 'ad_item'=>$row]);
    } catch (Throwable $e) {
      error_log("Ad item update error: " . $e->getMessage());
      json(['error'=>'server_error'], 500);
    }
  }

  // DELETE /admin/ad-items/{id} (soft-delete)
  if ($method === 'DELETE' && preg_match('#^/admin/ad-items/([0-9]+)$#', $uri, $m)) {
    require_session_auth(['admin', 'superadmin']);
    header('X-Ad-Items-Route: delete');
    try {
      $id = (int)$m[1];
      if ($id <= 0) { json(['error'=>'bad_id'], 422); }

      $pdo = db();
      
      // Soft delete: set deleted_at timestamp
      $st = $pdo->prepare("UPDATE ad_items SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
      $st->execute([$id]);
      
      if ($st->rowCount() === 0) {
        json(['error'=>'not_found'], 404);
      }
      
      json(['ok'=>true, 'deleted'=>true, 'id'=>$id]);
    } catch (Throwable $e) {
      error_log("Ad item delete error: " . $e->getMessage());
      json(['error'=>'server_error'], 500);
    }
  }

  // POST /admin/validate/{scope}/{id}
  if ($method === 'POST' && preg_match('#^/admin/validate/(ad-item|campaign|zone)/([0-9]+)$#', $uri, $m)) {
    $auth = require_session_auth();
    $scope = (string)$m[1];
    $id = (int)$m[2];
    if ($id <= 0) {
      json(['error' => 'bad_id'], 422);
    }

    try {
      $pdo = db();

      if ($scope === 'ad-item') {
        $activeStmt = $pdo->prepare('SELECT status, deleted_at FROM ad_items WHERE id = ? LIMIT 1');
        $activeStmt->execute([$id]);
        $row = $activeStmt->fetch(PDO::FETCH_ASSOC);
        if (!$row || !empty($row['deleted_at'])) {
          json(['error' => 'not_found'], 404);
        }
        if (strtolower((string)($row['status'] ?? '')) !== 'active') {
          json([
            'error' => 'validation_requires_active',
            'message' => 'Tests can only be run on Active Ad Items or Campaigns.',
            'scope' => 'ad_item',
            'id' => $id,
          ], 422);
        }
      }

      if ($scope === 'campaign') {
        $activeStmt = $pdo->prepare('SELECT status, deleted_at FROM campaigns WHERE id = ? LIMIT 1');
        $activeStmt->execute([$id]);
        $row = $activeStmt->fetch(PDO::FETCH_ASSOC);
        if (!$row || !empty($row['deleted_at'])) {
          json(['error' => 'not_found'], 404);
        }
        if (strtolower((string)($row['status'] ?? '')) !== 'active') {
          json([
            'error' => 'validation_requires_active',
            'message' => 'Tests can only be run on Active Ad Items or Campaigns.',
            'scope' => 'campaign',
            'id' => $id,
          ], 422);
        }
      }

      if ($scope === 'ad-item') {
        $result = ct_validate_ad_item($pdo, $id);
      } elseif ($scope === 'campaign') {
        $result = ct_validate_campaign($pdo, $id);
      } else {
        $result = ct_validate_zone($pdo, $id);
      }

      $log = ct_validation_log_run($pdo, $result, $auth);
      json([
        'ok' => true,
        'result' => $result,
        'run' => [
          'id' => $log['run_id'],
          'logged' => (bool)($log['logged'] ?? false),
          'reason' => $log['reason'] ?? null,
        ],
      ]);
    } catch (Throwable $e) {
      error_log('Validation route error: ' . $e->getMessage());
      json(['error' => 'server_error'], 500);
    }
  }

  // POST /admin/validate/confirm/ad-item/{id}
  if ($method === 'POST' && preg_match('#^/admin/validate/confirm/ad-item/([0-9]+)$#', $uri, $m)) {
    $auth = require_session_auth();
    $id = (int)$m[1];
    if ($id <= 0) {
      json(['error' => 'bad_id'], 422);
    }

    try {
      $pdo = db();
      $result = ct_validate_ad_item($pdo, $id);
      if (!ct_validation_can_confirm_size_mismatch($result)) {
        json(['error' => 'confirm_not_allowed', 'result' => $result], 422);
      }

      $result = ct_validation_apply_size_mismatch_confirm($result, $auth);
      $log = ct_validation_log_run($pdo, $result, $auth);
      json([
        'ok' => true,
        'result' => $result,
        'run' => [
          'id' => $log['run_id'],
          'logged' => (bool)($log['logged'] ?? false),
          'reason' => $log['reason'] ?? null,
        ],
      ]);
    } catch (Throwable $e) {
      error_log('Validation confirm route error: ' . $e->getMessage());
      json(['error' => 'server_error'], 500);
    }
  }

  // POST /admin/validate/confirm/campaign/{id}
  if ($method === 'POST' && preg_match('#^/admin/validate/confirm/campaign/([0-9]+)$#', $uri, $m)) {
    $auth = require_session_auth();
    $id = (int)$m[1];
    if ($id <= 0) {
      json(['error' => 'bad_id'], 422);
    }

    try {
      $pdo = db();
      $result = ct_validate_campaign($pdo, $id);
      if (!ct_validation_can_confirm_campaign_schedule($result)) {
        json(['error' => 'confirm_not_allowed', 'result' => $result], 422);
      }

      $result = ct_validation_apply_campaign_schedule_confirm($result, $auth);
      $log = ct_validation_log_run($pdo, $result, $auth);
      json([
        'ok' => true,
        'result' => $result,
        'run' => [
          'id' => $log['run_id'],
          'logged' => (bool)($log['logged'] ?? false),
          'reason' => $log['reason'] ?? null,
        ],
      ]);
    } catch (Throwable $e) {
      error_log('Validation campaign confirm route error: ' . $e->getMessage());
      json(['error' => 'server_error'], 500);
    }
  }

  // GET /admin/validate/history?scope_type=ad_item|campaign|zone&scope_id=123&limit=25
  if ($method === 'GET' && $uri === '/admin/validate/history') {
    require_session_auth();
    $scopeType = trim((string)($_GET['scope_type'] ?? ''));
    $scopeId = (int)($_GET['scope_id'] ?? 0);
    $limit = (int)($_GET['limit'] ?? 25);
    if ($limit < 1) {
      $limit = 1;
    }
    if ($limit > 200) {
      $limit = 200;
    }

    if (!in_array($scopeType, ['ad_item', 'campaign', 'zone'], true) || $scopeId <= 0) {
      json(['error' => 'bad_scope'], 422);
    }

    $pdo = db();
    if (!ct_table_exists($pdo, 'validation_runs')) {
      json(['ok' => true, 'count' => 0, 'items' => []]);
    }

    $stmt = $pdo->prepare("\n      SELECT id, scope_type, scope_id, status, error_count, warning_count, info_count, summary_json, issues_json, tested_by_user_id, tested_by_email, created_at\n      FROM validation_runs\n      WHERE scope_type = ? AND scope_id = ?\n      ORDER BY id DESC\n      LIMIT $limit\n    ");
    $stmt->execute([$scopeType, $scopeId]);
    $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
    json(['ok' => true, 'count' => count($items), 'items' => $items]);
  }

  // POST /admin/uploads (for ad item images)
  if ($method === 'POST' && $uri === '/admin/uploads') {
    header('X-Uploads-Route: ad-image');
    try {
      if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
        json(['error'=>'no_file'], 422);
      }
      $tmp  = $_FILES['file']['tmp_name'];
      $size = (int)($_FILES['file']['size'] ?? 0);

      $maxUploadBytes = max(1, (int)($_ENV['CT_UPLOAD_MAX_BYTES'] ?? (5 * 1024 * 1024)));
      if ($size <= 0 || $size > $maxUploadBytes) {
        $auth = $_SESSION['auth'] ?? null;
        $user = is_array($auth) ? [
          'id' => $auth['id'] ?? null,
          'email' => $auth['email'] ?? null,
          'role' => $auth['role'] ?? null,
        ] : null;
        
        SecurityLogger::logUploadReject('size_invalid', [
          'size_bytes' => $size,
          'max_bytes' => $maxUploadBytes,
          'filename' => $_FILES['file']['name'] ?? 'unknown',
        ], $user);
        
        json(['error' => 'upload_size_invalid'], 422);
      }

      $finfo = new finfo(FILEINFO_MIME_TYPE);
      $detectedMime = (string)($finfo->file($tmp) ?: '');
      $allowedMimes = [
        'image/png'  => 'png',
        'image/jpeg' => 'jpg',
        'image/gif'  => 'gif',
      ];
      if (!isset($allowedMimes[$detectedMime])) {
        $auth = $_SESSION['auth'] ?? null;
        $user = is_array($auth) ? [
          'id' => $auth['id'] ?? null,
          'email' => $auth['email'] ?? null,
          'role' => $auth['role'] ?? null,
        ] : null;
        
        SecurityLogger::logUploadReject('mime_type_invalid', [
          'detected_mime' => $detectedMime,
          'allowed_mimes' => array_keys($allowedMimes),
          'filename' => $_FILES['file']['name'] ?? 'unknown',
        ], $user);
        
        json(['error' => 'upload_invalid_type'], 422);
      }
      $mime = $detectedMime;
      $ext  = $allowedMimes[$detectedMime];

      $rand = bin2hex(random_bytes(16));
      $name = $rand . '.' . $ext;

      // Determine proper root path: use __DIR__ (current script dir = public/) as fallback
      $docRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
      $root = $docRoot ?: __DIR__;
      $root = rtrim($root, '/\\');
      
      // Build destination path using DIRECTORY_SEPARATOR for cross-platform compatibility
      $updir   = $root . DIRECTORY_SEPARATOR . 'uploads';
      $uprel   = '/uploads/' . $name;  // always use forward slash for URLs
      $destAbs = $updir . DIRECTORY_SEPARATOR . $name;
      
      // Ensure uploads directory exists
      if (!is_dir($updir)) {
        if (!@mkdir($updir, 0755, true)) {
          error_log('Upload failed: unable to create uploads directory');
          json(['error'=>'upload_failed'], 500);
        }
      }

      // Additional validation before move
      if (!is_readable($tmp)) {
        error_log('Upload failed: temporary file is not readable');
        json(['error'=>'upload_failed'], 500);
      }
      
      if (!is_writable($updir)) {
        error_log('Upload failed: uploads directory is not writable');
        json(['error' => 'upload_failed'], 500);
      }

      if (!@move_uploaded_file($tmp, $destAbs)) {
        error_log('Upload failed: move_uploaded_file returned false');
        json(['error' => 'upload_failed'], 500);
      }

      @chmod($destAbs, 0644);

      $w = null; $h = null;
      $dim = @getimagesize($destAbs);
      if (!$dim || !is_array($dim)) {
        @unlink($destAbs);
        json(['error' => 'upload_invalid_image'], 422);
      }
      $w = (int)$dim[0];
      $h = (int)$dim[1];

      $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
      $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
      $url    = $scheme.'://'.$host.$uprel;

      json(['ok'=>true, 'url'=>$url, 'path'=>$uprel, 'width'=>$w, 'height'=>$h, 'mime'=>$mime, 'size'=>$size]);
    } catch (Throwable $e) {
      error_log('Upload failed: ' . $e->getMessage());
      json(['error'=>'server_error'], 500);
    }
  }
  // ---------- /Ad Items ----------


  // ========== RECYCLE BIN (Soft-Delete Management) ==========
  
  // GET /admin/recycle-bin - List all soft-deleted records (Super Admin only)
  if ($method === 'GET' && $uri === '/admin/recycle-bin') {
    require_session_auth(['superadmin']);
    header('X-Route: recycle-bin-list');
    
    $pdo = db();
    $limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 100;
    
    $items = [];
    
    // Get deleted campaigns
    $sql = "SELECT id, name, 'campaign' AS type, advertiser_id AS parent_id, deleted_at
            FROM campaigns WHERE deleted_at IS NOT NULL
            ORDER BY deleted_at DESC LIMIT $limit";
    $st = $pdo->query($sql);
    $items = array_merge($items, $st->fetchAll(PDO::FETCH_ASSOC));
    
    // Get deleted advertisers
    $sql = "SELECT id, name, 'advertiser' AS type, NULL AS parent_id, deleted_at
            FROM advertisers WHERE deleted_at IS NOT NULL
            ORDER BY deleted_at DESC LIMIT $limit";
    $st = $pdo->query($sql);
    $items = array_merge($items, $st->fetchAll(PDO::FETCH_ASSOC));
    
    // Get deleted partners
    $sql = "SELECT id, name, 'partner' AS type, NULL AS parent_id, deleted_at
            FROM partners WHERE deleted_at IS NOT NULL
            ORDER BY deleted_at DESC LIMIT $limit";
    $st = $pdo->query($sql);
    $items = array_merge($items, $st->fetchAll(PDO::FETCH_ASSOC));
    
    // Get deleted zones
    $sql = "SELECT id, name, 'zone' AS type, partner_id AS parent_id, deleted_at
            FROM partner_zones WHERE deleted_at IS NOT NULL
            ORDER BY deleted_at DESC LIMIT $limit";
    $st = $pdo->query($sql);
    $items = array_merge($items, $st->fetchAll(PDO::FETCH_ASSOC));
    
    // Get deleted ad items
    $sql = "SELECT id, name, 'ad_item' AS type, campaign_id AS parent_id, deleted_at
            FROM ad_items WHERE deleted_at IS NOT NULL
            ORDER BY deleted_at DESC LIMIT $limit";
    $st = $pdo->query($sql);
    $items = array_merge($items, $st->fetchAll(PDO::FETCH_ASSOC));
    
    // Sort all by deleted_at DESC
    usort($items, fn($a, $b) => strcmp($b['deleted_at'], $a['deleted_at']));
    
    json(['count' => count($items), 'items' => $items]);
  }
  
  // POST /admin/recycle-bin/{entity}/{id}/restore - Restore a soft-deleted record
  if ($method === 'POST' && preg_match('#^/admin/recycle-bin/([a-z_]+)/([0-9]+)/restore$#', $uri, $m)) {
    require_session_auth(['superadmin']);
    header('X-Route: recycle-bin-restore');
    
    $entity = $m[1];
    $id = (int)$m[2];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }
    
    $pdo = db();
    
    // Map entity names to table names
    $tableMap = [
      'campaign' => 'campaigns',
      'advertiser' => 'advertisers',
      'partner' => 'partners',
      'zone' => 'partner_zones',
      'ad_item' => 'ad_items'
    ];
    
    if (!isset($tableMap[$entity])) {
      json(['error'=>'invalid_entity'], 422);
    }
    
    $table = $tableMap[$entity];
    
    // Restore by setting deleted_at to NULL
    $st = $pdo->prepare("UPDATE $table SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
    $st->execute([$id]);
    
    if ($st->rowCount() === 0) {
      json(['error'=>'not_found'], 404);
    }
    
    json(['ok'=>true, 'restored'=>true, 'entity'=>$entity, 'id'=>$id]);
  }
  
  // DELETE /admin/recycle-bin/{entity}/{id}/permanent - Permanently delete (Super Admin only)
  if ($method === 'DELETE' && preg_match('#^/admin/recycle-bin/([a-z_]+)/([0-9]+)/permanent$#', $uri, $m)) {
    require_session_auth(['superadmin']);
    header('X-Route: recycle-bin-permanent-delete');
    
    $entity = $m[1];
    $id = (int)$m[2];
    if ($id <= 0) { json(['error'=>'bad_id'], 422); }
    
    $pdo = db();
    
    // Map entity names to table names
    $tableMap = [
      'campaign' => 'campaigns',
      'advertiser' => 'advertisers',
      'partner' => 'partners',
      'zone' => 'partner_zones',
      'ad_item' => 'ad_items'
    ];
    
    if (!isset($tableMap[$entity])) {
      json(['error'=>'invalid_entity'], 422);
    }
    
    $table = $tableMap[$entity];
    
    // Permanently delete - only if already soft-deleted
    try {
      $st = $pdo->prepare("DELETE FROM $table WHERE id = ? AND deleted_at IS NOT NULL");
      $st->execute([$id]);
      
      if ($st->rowCount() === 0) {
        json(['error'=>'not_found'], 404);
      }
      
      json(['ok'=>true, 'permanently_deleted'=>true, 'entity'=>$entity, 'id'=>$id]);
    } catch (PDOException $e) {
      if ($e->getCode() === '23000') {
        json(['error'=>'has_dependencies', 'detail'=>'Cannot permanently delete: foreign key constraint'], 409);
      }
      throw $e;
    }
  }
  
  // ========== /RECYCLE BIN ==========






  // Admin fallthrough (keep last)
  header('X-Admin-Fallthrough: '.$method.' '.$uri);
  json(['error'=>'not_found'], 404);
}

// <-- END admin gate



